feat(kanban): dashboard plugin — Linear/Fusion-style board UI
Ships plugins/kanban/dashboard/ as a bundled dashboard plugin. No core
changes — uses the standard dashboard plugin contract (manifest.json +
dist/index.js + plugin_api.py) documented in 'Extending the Dashboard'.
What the tab gives you:
- One column per kanban status (todo / ready / running / blocked / done;
archived behind a toggle), column counts, coloured status dots.
- Cards with id, title, priority badge, tenant tag, assignee,
comment/link counts, 'created N ago'.
- HTML5 drag-drop between columns — status change routes through the
same kanban_db code the CLI /kanban verbs use, so the three surfaces
(CLI, gateway, dashboard) can never drift.
- Inline create per-column (title, assignee, priority).
- Side drawer on card click: description, status action row
(→ ready / → running / block / unblock / complete / archive),
dependency links, comment thread with Enter-to-submit,
last 20 events.
- Toolbar: search, tenant filter, assignee filter, show-archived,
nudge-dispatcher (skip the 60s wait), refresh.
- Live updates via WebSocket tailing task_events — the board reflects
CLI or gateway actions in real time.
REST surface under /api/plugins/kanban/: GET /board, GET /tasks/:id,
POST /tasks, PATCH /tasks/:id, POST /tasks/:id/comments, POST /links,
DELETE /links, POST /dispatch, WS /events. Every handler is a thin
wrapper around kanban_db — no new business logic.
Visually theme-aware: the plugin CSS reads only --color-*, --radius,
--font-mono etc. so it reskins with whichever dashboard theme is active.
Tests (tests/plugins/test_kanban_dashboard_plugin.py, 16 tests):
- empty board shape
- create + appears in ready column with tenant/assignee rollups
- tenant filter
- detail includes parents/children/events
- 404 on unknown task
- PATCH status: complete / block / unblock / ready drag-drop / running
- PATCH reassign, priority, edit, invalid-status rejection
- POST comment (plus empty-body rejection)
- POST link + DELETE link + cycle rejection
- POST dispatch (dry run)
All 76 kanban tests pass under scripts/run_tests.sh.
Docs: website/docs/user-guide/features/kanban.md gains a full
'Dashboard (GUI)' section covering install, architecture, REST surface,
live-updates mechanism, extending, and scope boundary.
2026-04-26 12:08:47 -07:00
|
|
|
"""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
|
feat(kanban): bulk ops, drawer edit, dep editor, markdown, touch, config
The dashboard plugin gets the last layer of features that turn it from a
'usable read surface with drag-drop' into a 'full kanban UI' — no more
'drop to CLI to do X' moments from inside the tab.
Plugin backend
- POST /tasks/bulk — apply the same patch (status / archive / assignee
/ priority) to every id in the request body. Each id runs
independently: one bad id reports {ok: false, error: ...} without
aborting siblings. Status transitions that aren't legal for the
current state are surfaced per-id ('transition to done refused').
Used by the multi-select bulk action bar.
- GET /config — returns the dashboard.kanban section of config.yaml
(default_tenant, lane_by_profile, include_archived_by_default,
render_markdown) with sensible defaults when the section is absent.
Loaded once by the SPA to preselect filters and toggle markdown
rendering.
- _conn() helper — every handler now goes through it, calling
kanban_db.init_db() (idempotent) before every connection. Fresh
installs work whether the first hit is GET /board, POST /tasks, or
any other endpoint — no more 'no such table: tasks' when the CLI
or a script hits the plugin before the dashboard has ever loaded.
Plugin UI (plugin bundle, +~12 KB)
- Multi-select: per-card checkbox; shift/ctrl-click also toggles
without opening the drawer. A BulkActionBar appears above the
columns with batch → ready / complete / archive / reassign
(profile dropdown + unassign option). Destructive batches confirm
first. Partial failures from the backend are surfaced inline.
- Drawer inline editing:
- Click the title → TitleEditor swaps in an input, Enter saves,
Escape cancels.
- Click the Assignee meta row → AssigneeEditor input (empty string
unassigns).
- Click the Priority meta row → PriorityEditor numeric input.
- New 'edit' button on Description → full-width textarea; Save /
Cancel switch back to rendered view.
- Dependency editor: chip list of parents + children with per-chip
× button (calls DELETE /links). Add-parent / add-child dropdowns
filter out self + already-linked tasks so you cannot re-add a
duplicate edge or a self-loop. Cycle rejections from the server
surface cleanly via the existing error banner.
- Parent selection in InlineCreate: new dropdown listing every task
on the board ('{id} — {title}') — picking one sends parents=[id]
with the create payload, so the task lands in todo (or triage if
created from the Triage column) with the dependency wired up.
- Safe markdown rendering for description, comment bodies, and
result. A small in-bundle renderer handles headings, bold, italic,
inline code, fenced code, bullet lists, and http(s)/mailto links.
Every substitution runs on HTML-escaped input (no raw HTML), links
get target=_blank + rel=noopener,noreferrer. Disabled by config
key dashboard.kanban.render_markdown=false (falls back to <pre>).
- Touch drag-drop: attachTouchDrag() installs a pointerdown handler
that spawns a drag proxy, tracks elementFromPoint under the finger,
and dispatches a hermes-kanban:drop CustomEvent on the column when
released. Desktop continues to use native HTML5 DnD. Columns
listen for both.
- ErrorBoundary already present from the prior commit catches any
renderer throw; markdown escape + touch-proxy cleanup both have
their own try/finally.
Tests (tests/plugins/test_kanban_dashboard_plugin.py — 90/90 pass)
- bulk_status_ready: 3 tasks blocked, batch → ready, all move
- bulk_archive hides all ids from default board
- bulk_reassign changes every assignee
- bulk_unassign_via_empty_string sets assignee back to None
- bulk_partial_failure_doesnt_abort_siblings: bogus id in middle,
good siblings still get priority=7
- bulk_empty_ids_400
- config_returns_defaults_when_section_missing
- config_reads_dashboard_kanban_section (writes config.yaml, verifies
every key round-trips)
Live smoke (real FastAPI app + isolated HERMES_HOME):
- /config without section returns defaults
- /config with dashboard.kanban section returns the configured values
- POST /tasks as the first-ever request (no prior /board) succeeds —
auto-init handles it
- Link add + remove via POST /links + DELETE /links round-trip
- Bulk priority bump on 2 ids, both get priority=5
- Bulk archive hides ids from default board
- PATCH {title, body} updates the task, markdown source survives
the round trip
- POST /tasks {triage: true, parents: [id]} lands in triage, not todo
- Bulk partial: 2 good + 1 bogus returns per-id outcome
Docs (website/docs/user-guide/features/kanban.md)
- 'What the plugin gives you' rewritten to reflect bulk, drawer
edit, dep editor, parent-on-create, markdown, touch drag-drop.
- New 'Dashboard config' subsection with a YAML example for
dashboard.kanban.*.
- REST table gains /tasks/bulk and /config rows.
2026-04-26 12:36:23 -07:00
|
|
|
import os
|
feat(kanban): dashboard plugin — Linear/Fusion-style board UI
Ships plugins/kanban/dashboard/ as a bundled dashboard plugin. No core
changes — uses the standard dashboard plugin contract (manifest.json +
dist/index.js + plugin_api.py) documented in 'Extending the Dashboard'.
What the tab gives you:
- One column per kanban status (todo / ready / running / blocked / done;
archived behind a toggle), column counts, coloured status dots.
- Cards with id, title, priority badge, tenant tag, assignee,
comment/link counts, 'created N ago'.
- HTML5 drag-drop between columns — status change routes through the
same kanban_db code the CLI /kanban verbs use, so the three surfaces
(CLI, gateway, dashboard) can never drift.
- Inline create per-column (title, assignee, priority).
- Side drawer on card click: description, status action row
(→ ready / → running / block / unblock / complete / archive),
dependency links, comment thread with Enter-to-submit,
last 20 events.
- Toolbar: search, tenant filter, assignee filter, show-archived,
nudge-dispatcher (skip the 60s wait), refresh.
- Live updates via WebSocket tailing task_events — the board reflects
CLI or gateway actions in real time.
REST surface under /api/plugins/kanban/: GET /board, GET /tasks/:id,
POST /tasks, PATCH /tasks/:id, POST /tasks/:id/comments, POST /links,
DELETE /links, POST /dispatch, WS /events. Every handler is a thin
wrapper around kanban_db — no new business logic.
Visually theme-aware: the plugin CSS reads only --color-*, --radius,
--font-mono etc. so it reskins with whichever dashboard theme is active.
Tests (tests/plugins/test_kanban_dashboard_plugin.py, 16 tests):
- empty board shape
- create + appears in ready column with tenant/assignee rollups
- tenant filter
- detail includes parents/children/events
- 404 on unknown task
- PATCH status: complete / block / unblock / ready drag-drop / running
- PATCH reassign, priority, edit, invalid-status rejection
- POST comment (plus empty-body rejection)
- POST link + DELETE link + cycle rejection
- POST dispatch (dry run)
All 76 kanban tests pass under scripts/run_tests.sh.
Docs: website/docs/user-guide/features/kanban.md gains a full
'Dashboard (GUI)' section covering install, architecture, REST surface,
live-updates mechanism, extending, and scope boundary.
2026-04-26 12:08:47 -07:00
|
|
|
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()
|
feat(kanban): Triage column, progress rollup, WS auth, lanes, polish
Follows up on the initial dashboard plugin with the items called out
during self-review — ships the GUI-reality claims the PR body made,
closes the WebSocket auth gap, and lands the 'Triage' status the design
spec's Fusion-style screenshot leads with.
Kernel changes
- kanban_db.VALID_STATUSES gains 'triage'. status is TEXT without a
CHECK constraint so no schema migration is needed.
- create_task(triage=True) forces the initial status to 'triage'
regardless of parents, and parent ids are still validated so the
eventual link rows don't dangle. recompute_ready() only promotes
'todo' -> 'ready', so triage tasks are naturally isolated from the
dispatcher pipeline.
- hermes kanban create gains --triage.
Patterns table (docs) gains P9 'Triage specifier'.
Plugin backend (plugins/kanban/dashboard/plugin_api.py)
- GET /board now auto-init's kanban.db on first read (idempotent).
A fresh install shows an empty board instead of 'failed to load'.
- GET /board returns a new 'progress' field per task — {done, total}
of child-task completion, or None if the task has no children.
- BOARD_COLUMNS prepends 'triage'.
- POST /tasks accepts {triage: bool}; PATCH /tasks/:id accepts
{status: 'triage'}.
- WebSocket /events now requires ?token=<session_token> as a query
param — browsers can't set Authorization on a WS upgrade, so this
matches the pattern the in-browser PTY bridge uses. Constant-time
compare against hermes_cli.web_server._SESSION_TOKEN. In bare-test
contexts (no dashboard module) the check no-ops so the tail loop
stays testable. Security boundary documented in the module header
and in website/docs/user-guide/features/kanban.md.
Plugin UI (plugins/kanban/dashboard/dist/index.js + style.css)
- Adds the Triage column (lilac dot) with helper text
'Raw ideas — a specifier will flesh out the spec'. Inline-create
from the Triage column parks new tasks in triage.
- Status action row in the drawer gains '→ triage'.
- Progress pill (N/M) on cards that have children. Full-complete
state tints the pill green.
- 'Lanes by profile' toolbar toggle — sub-groups the Running column
by assignee so you see at a glance which specialist is busy on
what.
- Destructive status moves (done / archived / blocked) via drag-drop
OR via the drawer action row now prompt for confirmation.
- Escape closes the drawer.
- Live-update reloads are debounced (250ms) so a burst of
task_events triggers one refetch, not N.
- WebSocket includes ?token= built from window.__HERMES_SESSION_TOKEN__.
- WebSocket reconnect uses exponential backoff capped at 30s, not
a fixed 1.5s spin loop, and surfaces a user-visible error on
code-1008 (auth rejected) instead of reconnecting forever.
- ErrorBoundary wraps the page — a bad card render shows a
'rendering error, reload view' card instead of crashing the tab.
Tests (tests/plugins/test_kanban_dashboard_plugin.py, +5 tests = 21)
- empty-board shape now asserts all 6 columns including 'triage'
- create_triage_lands_in_triage_column
- triage_task_not_promoted_to_ready (dispatcher bypasses triage)
- patch_status_triage_works (both into triage and out of it)
- board_progress_rollup (0/2 -> 1/2 -> childless cards = None)
- board_auto_initializes_missing_db
- ws_events_rejects_when_token_required (three sub-assertions:
missing → 1008, wrong → 1008, correct → handshake accepted)
All 82 kanban tests pass under scripts/run_tests.sh.
Docs
- kanban.md 'What the plugin gives you' fully rewritten to match
shipped reality (triage, progress pill, assignee lanes,
destructive-confirm, Escape-close, debounce).
- New 'Security model' subsection documents the explicit-plugin-
route-bypass, the WS token requirement, and the --host 0.0.0.0
warning; also notes that kanban.db is profile-agnostic on purpose
(the coordination primitive) so cross-profile visibility is
expected.
- CLI command reference shows --triage.
- Collaboration patterns table adds P9 'Triage specifier'.
2026-04-26 12:26:43 -07:00
|
|
|
# All canonical columns present (triage + the rest), each empty.
|
feat(kanban): dashboard plugin — Linear/Fusion-style board UI
Ships plugins/kanban/dashboard/ as a bundled dashboard plugin. No core
changes — uses the standard dashboard plugin contract (manifest.json +
dist/index.js + plugin_api.py) documented in 'Extending the Dashboard'.
What the tab gives you:
- One column per kanban status (todo / ready / running / blocked / done;
archived behind a toggle), column counts, coloured status dots.
- Cards with id, title, priority badge, tenant tag, assignee,
comment/link counts, 'created N ago'.
- HTML5 drag-drop between columns — status change routes through the
same kanban_db code the CLI /kanban verbs use, so the three surfaces
(CLI, gateway, dashboard) can never drift.
- Inline create per-column (title, assignee, priority).
- Side drawer on card click: description, status action row
(→ ready / → running / block / unblock / complete / archive),
dependency links, comment thread with Enter-to-submit,
last 20 events.
- Toolbar: search, tenant filter, assignee filter, show-archived,
nudge-dispatcher (skip the 60s wait), refresh.
- Live updates via WebSocket tailing task_events — the board reflects
CLI or gateway actions in real time.
REST surface under /api/plugins/kanban/: GET /board, GET /tasks/:id,
POST /tasks, PATCH /tasks/:id, POST /tasks/:id/comments, POST /links,
DELETE /links, POST /dispatch, WS /events. Every handler is a thin
wrapper around kanban_db — no new business logic.
Visually theme-aware: the plugin CSS reads only --color-*, --radius,
--font-mono etc. so it reskins with whichever dashboard theme is active.
Tests (tests/plugins/test_kanban_dashboard_plugin.py, 16 tests):
- empty board shape
- create + appears in ready column with tenant/assignee rollups
- tenant filter
- detail includes parents/children/events
- 404 on unknown task
- PATCH status: complete / block / unblock / ready drag-drop / running
- PATCH reassign, priority, edit, invalid-status rejection
- POST comment (plus empty-body rejection)
- POST link + DELETE link + cycle rejection
- POST dispatch (dry run)
All 76 kanban tests pass under scripts/run_tests.sh.
Docs: website/docs/user-guide/features/kanban.md gains a full
'Dashboard (GUI)' section covering install, architecture, REST surface,
live-updates mechanism, extending, and scope boundary.
2026-04-26 12:08:47 -07:00
|
|
|
names = [c["name"] for c in data["columns"]]
|
feat(kanban): Triage column, progress rollup, WS auth, lanes, polish
Follows up on the initial dashboard plugin with the items called out
during self-review — ships the GUI-reality claims the PR body made,
closes the WebSocket auth gap, and lands the 'Triage' status the design
spec's Fusion-style screenshot leads with.
Kernel changes
- kanban_db.VALID_STATUSES gains 'triage'. status is TEXT without a
CHECK constraint so no schema migration is needed.
- create_task(triage=True) forces the initial status to 'triage'
regardless of parents, and parent ids are still validated so the
eventual link rows don't dangle. recompute_ready() only promotes
'todo' -> 'ready', so triage tasks are naturally isolated from the
dispatcher pipeline.
- hermes kanban create gains --triage.
Patterns table (docs) gains P9 'Triage specifier'.
Plugin backend (plugins/kanban/dashboard/plugin_api.py)
- GET /board now auto-init's kanban.db on first read (idempotent).
A fresh install shows an empty board instead of 'failed to load'.
- GET /board returns a new 'progress' field per task — {done, total}
of child-task completion, or None if the task has no children.
- BOARD_COLUMNS prepends 'triage'.
- POST /tasks accepts {triage: bool}; PATCH /tasks/:id accepts
{status: 'triage'}.
- WebSocket /events now requires ?token=<session_token> as a query
param — browsers can't set Authorization on a WS upgrade, so this
matches the pattern the in-browser PTY bridge uses. Constant-time
compare against hermes_cli.web_server._SESSION_TOKEN. In bare-test
contexts (no dashboard module) the check no-ops so the tail loop
stays testable. Security boundary documented in the module header
and in website/docs/user-guide/features/kanban.md.
Plugin UI (plugins/kanban/dashboard/dist/index.js + style.css)
- Adds the Triage column (lilac dot) with helper text
'Raw ideas — a specifier will flesh out the spec'. Inline-create
from the Triage column parks new tasks in triage.
- Status action row in the drawer gains '→ triage'.
- Progress pill (N/M) on cards that have children. Full-complete
state tints the pill green.
- 'Lanes by profile' toolbar toggle — sub-groups the Running column
by assignee so you see at a glance which specialist is busy on
what.
- Destructive status moves (done / archived / blocked) via drag-drop
OR via the drawer action row now prompt for confirmation.
- Escape closes the drawer.
- Live-update reloads are debounced (250ms) so a burst of
task_events triggers one refetch, not N.
- WebSocket includes ?token= built from window.__HERMES_SESSION_TOKEN__.
- WebSocket reconnect uses exponential backoff capped at 30s, not
a fixed 1.5s spin loop, and surfaces a user-visible error on
code-1008 (auth rejected) instead of reconnecting forever.
- ErrorBoundary wraps the page — a bad card render shows a
'rendering error, reload view' card instead of crashing the tab.
Tests (tests/plugins/test_kanban_dashboard_plugin.py, +5 tests = 21)
- empty-board shape now asserts all 6 columns including 'triage'
- create_triage_lands_in_triage_column
- triage_task_not_promoted_to_ready (dispatcher bypasses triage)
- patch_status_triage_works (both into triage and out of it)
- board_progress_rollup (0/2 -> 1/2 -> childless cards = None)
- board_auto_initializes_missing_db
- ws_events_rejects_when_token_required (three sub-assertions:
missing → 1008, wrong → 1008, correct → handshake accepted)
All 82 kanban tests pass under scripts/run_tests.sh.
Docs
- kanban.md 'What the plugin gives you' fully rewritten to match
shipped reality (triage, progress pill, assignee lanes,
destructive-confirm, Escape-close, debounce).
- New 'Security model' subsection documents the explicit-plugin-
route-bypass, the WS token requirement, and the --host 0.0.0.0
warning; also notes that kanban.db is profile-agnostic on purpose
(the coordination primitive) so cross-profile visibility is
expected.
- CLI command reference shows --triage.
- Collaboration patterns table adds P9 'Triage specifier'.
2026-04-26 12:26:43 -07:00
|
|
|
for expected in ("triage", "todo", "ready", "running", "blocked", "done"):
|
feat(kanban): dashboard plugin — Linear/Fusion-style board UI
Ships plugins/kanban/dashboard/ as a bundled dashboard plugin. No core
changes — uses the standard dashboard plugin contract (manifest.json +
dist/index.js + plugin_api.py) documented in 'Extending the Dashboard'.
What the tab gives you:
- One column per kanban status (todo / ready / running / blocked / done;
archived behind a toggle), column counts, coloured status dots.
- Cards with id, title, priority badge, tenant tag, assignee,
comment/link counts, 'created N ago'.
- HTML5 drag-drop between columns — status change routes through the
same kanban_db code the CLI /kanban verbs use, so the three surfaces
(CLI, gateway, dashboard) can never drift.
- Inline create per-column (title, assignee, priority).
- Side drawer on card click: description, status action row
(→ ready / → running / block / unblock / complete / archive),
dependency links, comment thread with Enter-to-submit,
last 20 events.
- Toolbar: search, tenant filter, assignee filter, show-archived,
nudge-dispatcher (skip the 60s wait), refresh.
- Live updates via WebSocket tailing task_events — the board reflects
CLI or gateway actions in real time.
REST surface under /api/plugins/kanban/: GET /board, GET /tasks/:id,
POST /tasks, PATCH /tasks/:id, POST /tasks/:id/comments, POST /links,
DELETE /links, POST /dispatch, WS /events. Every handler is a thin
wrapper around kanban_db — no new business logic.
Visually theme-aware: the plugin CSS reads only --color-*, --radius,
--font-mono etc. so it reskins with whichever dashboard theme is active.
Tests (tests/plugins/test_kanban_dashboard_plugin.py, 16 tests):
- empty board shape
- create + appears in ready column with tenant/assignee rollups
- tenant filter
- detail includes parents/children/events
- 404 on unknown task
- PATCH status: complete / block / unblock / ready drag-drop / running
- PATCH reassign, priority, edit, invalid-status rejection
- POST comment (plus empty-body rejection)
- POST link + DELETE link + cycle rejection
- POST dispatch (dry run)
All 76 kanban tests pass under scripts/run_tests.sh.
Docs: website/docs/user-guide/features/kanban.md gains a full
'Dashboard (GUI)' section covering install, architecture, REST surface,
live-updates mechanism, extending, and scope boundary.
2026-04-26 12:08:47 -07:00
|
|
|
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)
|
feat(kanban): Triage column, progress rollup, WS auth, lanes, polish
Follows up on the initial dashboard plugin with the items called out
during self-review — ships the GUI-reality claims the PR body made,
closes the WebSocket auth gap, and lands the 'Triage' status the design
spec's Fusion-style screenshot leads with.
Kernel changes
- kanban_db.VALID_STATUSES gains 'triage'. status is TEXT without a
CHECK constraint so no schema migration is needed.
- create_task(triage=True) forces the initial status to 'triage'
regardless of parents, and parent ids are still validated so the
eventual link rows don't dangle. recompute_ready() only promotes
'todo' -> 'ready', so triage tasks are naturally isolated from the
dispatcher pipeline.
- hermes kanban create gains --triage.
Patterns table (docs) gains P9 'Triage specifier'.
Plugin backend (plugins/kanban/dashboard/plugin_api.py)
- GET /board now auto-init's kanban.db on first read (idempotent).
A fresh install shows an empty board instead of 'failed to load'.
- GET /board returns a new 'progress' field per task — {done, total}
of child-task completion, or None if the task has no children.
- BOARD_COLUMNS prepends 'triage'.
- POST /tasks accepts {triage: bool}; PATCH /tasks/:id accepts
{status: 'triage'}.
- WebSocket /events now requires ?token=<session_token> as a query
param — browsers can't set Authorization on a WS upgrade, so this
matches the pattern the in-browser PTY bridge uses. Constant-time
compare against hermes_cli.web_server._SESSION_TOKEN. In bare-test
contexts (no dashboard module) the check no-ops so the tail loop
stays testable. Security boundary documented in the module header
and in website/docs/user-guide/features/kanban.md.
Plugin UI (plugins/kanban/dashboard/dist/index.js + style.css)
- Adds the Triage column (lilac dot) with helper text
'Raw ideas — a specifier will flesh out the spec'. Inline-create
from the Triage column parks new tasks in triage.
- Status action row in the drawer gains '→ triage'.
- Progress pill (N/M) on cards that have children. Full-complete
state tints the pill green.
- 'Lanes by profile' toolbar toggle — sub-groups the Running column
by assignee so you see at a glance which specialist is busy on
what.
- Destructive status moves (done / archived / blocked) via drag-drop
OR via the drawer action row now prompt for confirmation.
- Escape closes the drawer.
- Live-update reloads are debounced (250ms) so a burst of
task_events triggers one refetch, not N.
- WebSocket includes ?token= built from window.__HERMES_SESSION_TOKEN__.
- WebSocket reconnect uses exponential backoff capped at 30s, not
a fixed 1.5s spin loop, and surfaces a user-visible error on
code-1008 (auth rejected) instead of reconnecting forever.
- ErrorBoundary wraps the page — a bad card render shows a
'rendering error, reload view' card instead of crashing the tab.
Tests (tests/plugins/test_kanban_dashboard_plugin.py, +5 tests = 21)
- empty-board shape now asserts all 6 columns including 'triage'
- create_triage_lands_in_triage_column
- triage_task_not_promoted_to_ready (dispatcher bypasses triage)
- patch_status_triage_works (both into triage and out of it)
- board_progress_rollup (0/2 -> 1/2 -> childless cards = None)
- board_auto_initializes_missing_db
- ws_events_rejects_when_token_required (three sub-assertions:
missing → 1008, wrong → 1008, correct → handshake accepted)
All 82 kanban tests pass under scripts/run_tests.sh.
Docs
- kanban.md 'What the plugin gives you' fully rewritten to match
shipped reality (triage, progress pill, assignee lanes,
destructive-confirm, Escape-close, debounce).
- New 'Security model' subsection documents the explicit-plugin-
route-bypass, the WS token requirement, and the --host 0.0.0.0
warning; also notes that kanban.db is profile-agnostic on purpose
(the coordination primitive) so cross-profile visibility is
expected.
- CLI command reference shows --triage.
- Collaboration patterns table adds P9 'Triage specifier'.
2026-04-26 12:26:43 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Triage column (new v1 status)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_create_triage_lands_in_triage_column(client):
|
|
|
|
|
r = client.post(
|
|
|
|
|
"/api/plugins/kanban/tasks",
|
|
|
|
|
json={"title": "rough idea, spec me", "triage": True},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
task = r.json()["task"]
|
|
|
|
|
assert task["status"] == "triage"
|
|
|
|
|
|
|
|
|
|
r = client.get("/api/plugins/kanban/board")
|
|
|
|
|
triage = next(c for c in r.json()["columns"] if c["name"] == "triage")
|
|
|
|
|
assert len(triage["tasks"]) == 1
|
|
|
|
|
assert triage["tasks"][0]["title"] == "rough idea, spec me"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_triage_task_not_promoted_to_ready(client):
|
|
|
|
|
"""Triage tasks must stay in triage even when they have no parents."""
|
|
|
|
|
client.post(
|
|
|
|
|
"/api/plugins/kanban/tasks",
|
|
|
|
|
json={"title": "must stay put", "triage": True},
|
|
|
|
|
)
|
|
|
|
|
# Run the dispatcher — it should NOT promote the triage task.
|
|
|
|
|
client.post("/api/plugins/kanban/dispatch?dry_run=false&max=4")
|
|
|
|
|
r = client.get("/api/plugins/kanban/board")
|
|
|
|
|
triage = next(c for c in r.json()["columns"] if c["name"] == "triage")
|
|
|
|
|
ready = next(c for c in r.json()["columns"] if c["name"] == "ready")
|
|
|
|
|
assert len(triage["tasks"]) == 1
|
|
|
|
|
assert len(ready["tasks"]) == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_patch_status_triage_works(client):
|
|
|
|
|
"""A user (or specifier) can push a task back into triage, and out of it."""
|
|
|
|
|
t = client.post(
|
|
|
|
|
"/api/plugins/kanban/tasks", json={"title": "x"},
|
|
|
|
|
).json()["task"]
|
|
|
|
|
# Normal creation is 'ready'; push to triage.
|
|
|
|
|
r = client.patch(
|
|
|
|
|
f"/api/plugins/kanban/tasks/{t['id']}", json={"status": "triage"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json()["task"]["status"] == "triage"
|
|
|
|
|
|
|
|
|
|
# Now promote to todo.
|
|
|
|
|
r = client.patch(
|
|
|
|
|
f"/api/plugins/kanban/tasks/{t['id']}", json={"status": "todo"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert r.json()["task"]["status"] == "todo"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Progress rollup (done children / total children)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_board_progress_rollup(client):
|
|
|
|
|
parent = client.post(
|
|
|
|
|
"/api/plugins/kanban/tasks", json={"title": "parent"},
|
|
|
|
|
).json()["task"]
|
|
|
|
|
child_a = client.post(
|
|
|
|
|
"/api/plugins/kanban/tasks",
|
|
|
|
|
json={"title": "a", "parents": [parent["id"]]},
|
|
|
|
|
).json()["task"]
|
|
|
|
|
child_b = client.post(
|
|
|
|
|
"/api/plugins/kanban/tasks",
|
|
|
|
|
json={"title": "b", "parents": [parent["id"]]},
|
|
|
|
|
).json()["task"]
|
|
|
|
|
# Children start as "todo" because the parent isn't done yet; promote
|
|
|
|
|
# them to "ready" so complete_task will accept the transition.
|
|
|
|
|
for cid in (child_a["id"], child_b["id"]):
|
|
|
|
|
r = client.patch(
|
|
|
|
|
f"/api/plugins/kanban/tasks/{cid}", json={"status": "ready"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
|
|
|
|
|
# 0/2 done.
|
|
|
|
|
r = client.get("/api/plugins/kanban/board")
|
|
|
|
|
parent_row = next(
|
|
|
|
|
t for col in r.json()["columns"] for t in col["tasks"]
|
|
|
|
|
if t["id"] == parent["id"]
|
|
|
|
|
)
|
|
|
|
|
assert parent_row["progress"] == {"done": 0, "total": 2}
|
|
|
|
|
|
|
|
|
|
# Complete one child. 1/2.
|
|
|
|
|
r = client.patch(
|
|
|
|
|
f"/api/plugins/kanban/tasks/{child_a['id']}",
|
|
|
|
|
json={"status": "done"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
r = client.get("/api/plugins/kanban/board")
|
|
|
|
|
parent_row = next(
|
|
|
|
|
t for col in r.json()["columns"] for t in col["tasks"]
|
|
|
|
|
if t["id"] == parent["id"]
|
|
|
|
|
)
|
|
|
|
|
assert parent_row["progress"] == {"done": 1, "total": 2}
|
|
|
|
|
|
|
|
|
|
# Childless tasks report progress=None, not {0/0}.
|
|
|
|
|
assert next(
|
|
|
|
|
t for col in r.json()["columns"] for t in col["tasks"]
|
|
|
|
|
if t["id"] == child_b["id"]
|
|
|
|
|
)["progress"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Auto-init on first board read
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_board_auto_initializes_missing_db(tmp_path, monkeypatch):
|
|
|
|
|
"""If kanban.db doesn't exist yet, GET /board must create it, not 500."""
|
|
|
|
|
home = tmp_path / ".hermes"
|
|
|
|
|
home.mkdir()
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
|
|
|
# Deliberately DO NOT call kb.init_db().
|
|
|
|
|
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
|
|
|
|
c = TestClient(app)
|
|
|
|
|
r = c.get("/api/plugins/kanban/board")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert (home / "kanban.db").exists(), "init_db wasn't invoked by /board"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# WebSocket auth (query-param token)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
|
|
|
|
|
"""When _SESSION_TOKEN is set (normal dashboard context), a missing or
|
|
|
|
|
wrong ?token= query param must be rejected with policy-violation."""
|
|
|
|
|
home = tmp_path / ".hermes"
|
|
|
|
|
home.mkdir()
|
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
|
monkeypatch.setattr(Path, "home", lambda: tmp_path)
|
|
|
|
|
kb.init_db()
|
|
|
|
|
|
|
|
|
|
# Stub web_server so _check_ws_token has a token to compare against.
|
|
|
|
|
import types
|
|
|
|
|
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
|
|
|
|
|
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
|
|
|
|
|
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
|
|
|
|
|
c = TestClient(app)
|
|
|
|
|
|
|
|
|
|
# No token → policy violation close.
|
|
|
|
|
from starlette.websockets import WebSocketDisconnect
|
|
|
|
|
with pytest.raises(WebSocketDisconnect) as exc:
|
|
|
|
|
with c.websocket_connect("/api/plugins/kanban/events"):
|
|
|
|
|
pass
|
|
|
|
|
assert exc.value.code == 1008
|
|
|
|
|
|
|
|
|
|
# Wrong token → policy violation close.
|
|
|
|
|
with pytest.raises(WebSocketDisconnect) as exc:
|
|
|
|
|
with c.websocket_connect("/api/plugins/kanban/events?token=nope"):
|
|
|
|
|
pass
|
|
|
|
|
assert exc.value.code == 1008
|
|
|
|
|
|
|
|
|
|
# Correct token → accepted (connect then close cleanly from our side).
|
|
|
|
|
with c.websocket_connect(
|
|
|
|
|
"/api/plugins/kanban/events?token=secret-xyz"
|
|
|
|
|
) as ws:
|
|
|
|
|
assert ws is not None # handshake succeeded
|
feat(kanban): bulk ops, drawer edit, dep editor, markdown, touch, config
The dashboard plugin gets the last layer of features that turn it from a
'usable read surface with drag-drop' into a 'full kanban UI' — no more
'drop to CLI to do X' moments from inside the tab.
Plugin backend
- POST /tasks/bulk — apply the same patch (status / archive / assignee
/ priority) to every id in the request body. Each id runs
independently: one bad id reports {ok: false, error: ...} without
aborting siblings. Status transitions that aren't legal for the
current state are surfaced per-id ('transition to done refused').
Used by the multi-select bulk action bar.
- GET /config — returns the dashboard.kanban section of config.yaml
(default_tenant, lane_by_profile, include_archived_by_default,
render_markdown) with sensible defaults when the section is absent.
Loaded once by the SPA to preselect filters and toggle markdown
rendering.
- _conn() helper — every handler now goes through it, calling
kanban_db.init_db() (idempotent) before every connection. Fresh
installs work whether the first hit is GET /board, POST /tasks, or
any other endpoint — no more 'no such table: tasks' when the CLI
or a script hits the plugin before the dashboard has ever loaded.
Plugin UI (plugin bundle, +~12 KB)
- Multi-select: per-card checkbox; shift/ctrl-click also toggles
without opening the drawer. A BulkActionBar appears above the
columns with batch → ready / complete / archive / reassign
(profile dropdown + unassign option). Destructive batches confirm
first. Partial failures from the backend are surfaced inline.
- Drawer inline editing:
- Click the title → TitleEditor swaps in an input, Enter saves,
Escape cancels.
- Click the Assignee meta row → AssigneeEditor input (empty string
unassigns).
- Click the Priority meta row → PriorityEditor numeric input.
- New 'edit' button on Description → full-width textarea; Save /
Cancel switch back to rendered view.
- Dependency editor: chip list of parents + children with per-chip
× button (calls DELETE /links). Add-parent / add-child dropdowns
filter out self + already-linked tasks so you cannot re-add a
duplicate edge or a self-loop. Cycle rejections from the server
surface cleanly via the existing error banner.
- Parent selection in InlineCreate: new dropdown listing every task
on the board ('{id} — {title}') — picking one sends parents=[id]
with the create payload, so the task lands in todo (or triage if
created from the Triage column) with the dependency wired up.
- Safe markdown rendering for description, comment bodies, and
result. A small in-bundle renderer handles headings, bold, italic,
inline code, fenced code, bullet lists, and http(s)/mailto links.
Every substitution runs on HTML-escaped input (no raw HTML), links
get target=_blank + rel=noopener,noreferrer. Disabled by config
key dashboard.kanban.render_markdown=false (falls back to <pre>).
- Touch drag-drop: attachTouchDrag() installs a pointerdown handler
that spawns a drag proxy, tracks elementFromPoint under the finger,
and dispatches a hermes-kanban:drop CustomEvent on the column when
released. Desktop continues to use native HTML5 DnD. Columns
listen for both.
- ErrorBoundary already present from the prior commit catches any
renderer throw; markdown escape + touch-proxy cleanup both have
their own try/finally.
Tests (tests/plugins/test_kanban_dashboard_plugin.py — 90/90 pass)
- bulk_status_ready: 3 tasks blocked, batch → ready, all move
- bulk_archive hides all ids from default board
- bulk_reassign changes every assignee
- bulk_unassign_via_empty_string sets assignee back to None
- bulk_partial_failure_doesnt_abort_siblings: bogus id in middle,
good siblings still get priority=7
- bulk_empty_ids_400
- config_returns_defaults_when_section_missing
- config_reads_dashboard_kanban_section (writes config.yaml, verifies
every key round-trips)
Live smoke (real FastAPI app + isolated HERMES_HOME):
- /config without section returns defaults
- /config with dashboard.kanban section returns the configured values
- POST /tasks as the first-ever request (no prior /board) succeeds —
auto-init handles it
- Link add + remove via POST /links + DELETE /links round-trip
- Bulk priority bump on 2 ids, both get priority=5
- Bulk archive hides ids from default board
- PATCH {title, body} updates the task, markdown source survives
the round trip
- POST /tasks {triage: true, parents: [id]} lands in triage, not todo
- Bulk partial: 2 good + 1 bogus returns per-id outcome
Docs (website/docs/user-guide/features/kanban.md)
- 'What the plugin gives you' rewritten to reflect bulk, drawer
edit, dep editor, parent-on-create, markdown, touch drag-drop.
- New 'Dashboard config' subsection with a YAML example for
dashboard.kanban.*.
- REST table gains /tasks/bulk and /config rows.
2026-04-26 12:36:23 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Bulk actions
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bulk_status_ready(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"]
|
|
|
|
|
c2 = client.post("/api/plugins/kanban/tasks", json={"title": "c"}).json()["task"]
|
|
|
|
|
# Parent-less tasks land in "ready" already; push them to blocked first.
|
|
|
|
|
for tid in (a["id"], b["id"], c2["id"]):
|
|
|
|
|
client.patch(f"/api/plugins/kanban/tasks/{tid}",
|
|
|
|
|
json={"status": "blocked", "block_reason": "wait"})
|
|
|
|
|
|
|
|
|
|
r = client.post("/api/plugins/kanban/tasks/bulk",
|
|
|
|
|
json={"ids": [a["id"], b["id"], c2["id"]], "status": "ready"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
results = r.json()["results"]
|
|
|
|
|
assert all(r["ok"] for r in results)
|
|
|
|
|
# All three are now ready.
|
|
|
|
|
board = client.get("/api/plugins/kanban/board").json()
|
|
|
|
|
ready = next(col for col in board["columns"] if col["name"] == "ready")
|
|
|
|
|
ids = {t["id"] for t in ready["tasks"]}
|
|
|
|
|
assert {a["id"], b["id"], c2["id"]}.issubset(ids)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bulk_archive(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/tasks/bulk",
|
|
|
|
|
json={"ids": [a["id"], b["id"]], "archive": True})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
assert all(r["ok"] for r in r.json()["results"])
|
|
|
|
|
# Default board (archived hidden) — both gone.
|
|
|
|
|
board = client.get("/api/plugins/kanban/board").json()
|
|
|
|
|
ids = {t["id"] for col in board["columns"] for t in col["tasks"]}
|
|
|
|
|
assert a["id"] not in ids
|
|
|
|
|
assert b["id"] not in ids
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bulk_reassign(client):
|
|
|
|
|
a = client.post("/api/plugins/kanban/tasks",
|
|
|
|
|
json={"title": "a", "assignee": "old"}).json()["task"]
|
|
|
|
|
b = client.post("/api/plugins/kanban/tasks",
|
|
|
|
|
json={"title": "b", "assignee": "old"}).json()["task"]
|
|
|
|
|
r = client.post("/api/plugins/kanban/tasks/bulk",
|
|
|
|
|
json={"ids": [a["id"], b["id"]], "assignee": "new"})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
for tid in (a["id"], b["id"]):
|
|
|
|
|
t = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"]
|
|
|
|
|
assert t["assignee"] == "new"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bulk_unassign_via_empty_string(client):
|
|
|
|
|
a = client.post("/api/plugins/kanban/tasks",
|
|
|
|
|
json={"title": "a", "assignee": "x"}).json()["task"]
|
|
|
|
|
r = client.post("/api/plugins/kanban/tasks/bulk",
|
|
|
|
|
json={"ids": [a["id"]], "assignee": ""})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
t = client.get(f"/api/plugins/kanban/tasks/{a['id']}").json()["task"]
|
|
|
|
|
assert t["assignee"] is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bulk_partial_failure_doesnt_abort_siblings(client):
|
|
|
|
|
"""One bad id in the middle of a batch must not prevent others from
|
|
|
|
|
applying."""
|
|
|
|
|
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
|
|
|
|
|
c2 = client.post("/api/plugins/kanban/tasks", json={"title": "c"}).json()["task"]
|
|
|
|
|
r = client.post("/api/plugins/kanban/tasks/bulk",
|
|
|
|
|
json={"ids": [a["id"], "bogus-id", c2["id"]], "priority": 7})
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
results = r.json()["results"]
|
|
|
|
|
assert len(results) == 3
|
|
|
|
|
ok_ids = {r["id"] for r in results if r["ok"]}
|
|
|
|
|
assert a["id"] in ok_ids
|
|
|
|
|
assert c2["id"] in ok_ids
|
|
|
|
|
assert any(not r["ok"] and r["id"] == "bogus-id" for r in results)
|
|
|
|
|
# Good siblings actually got the priority bump.
|
|
|
|
|
for tid in (a["id"], c2["id"]):
|
|
|
|
|
t = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"]
|
|
|
|
|
assert t["priority"] == 7
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_bulk_empty_ids_400(client):
|
|
|
|
|
r = client.post("/api/plugins/kanban/tasks/bulk", json={"ids": []})
|
|
|
|
|
assert r.status_code == 400
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# /config endpoint
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_config_returns_defaults_when_section_missing(client):
|
|
|
|
|
r = client.get("/api/plugins/kanban/config")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
data = r.json()
|
|
|
|
|
# Defaults when dashboard.kanban is missing.
|
|
|
|
|
assert data["default_tenant"] == ""
|
|
|
|
|
assert data["lane_by_profile"] is True
|
|
|
|
|
assert data["include_archived_by_default"] is False
|
|
|
|
|
assert data["render_markdown"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_config_reads_dashboard_kanban_section(tmp_path, monkeypatch, client):
|
|
|
|
|
home = Path(os.environ["HERMES_HOME"])
|
|
|
|
|
(home / "config.yaml").write_text(
|
|
|
|
|
"dashboard:\n"
|
|
|
|
|
" kanban:\n"
|
|
|
|
|
" default_tenant: acme\n"
|
|
|
|
|
" lane_by_profile: false\n"
|
|
|
|
|
" include_archived_by_default: true\n"
|
|
|
|
|
" render_markdown: false\n"
|
|
|
|
|
)
|
|
|
|
|
r = client.get("/api/plugins/kanban/config")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
data = r.json()
|
|
|
|
|
assert data["default_tenant"] == "acme"
|
|
|
|
|
assert data["lane_by_profile"] is False
|
|
|
|
|
assert data["include_archived_by_default"] is True
|
|
|
|
|
assert data["render_markdown"] is False
|
feat(kanban): runs as first-class (v1); structured handoffs; forward-compat for v2 workflows
Addresses vulcan-artivus's RFC review on issue #16102. Picks up the
structural changes that are expensive to retrofit later and zero-cost
to land now; defers workflow-template routing + per-stage lanes to v2
(kept forward-compat hooks in the schema).
Kernel
- New `task_runs` table. Each claim opens a run (pid, claim_lock,
heartbeat, max_runtime, started_at), each terminal transition
closes it with an outcome (completed / blocked / crashed /
timed_out / spawn_failed / gave_up / reclaimed). Multiple rows per
task when retries happen, preserving full attempt history.
- `tasks.current_run_id` points at the active run (NULL when idle);
denormalised for cheap reads.
- `task_events.run_id` carries the run a given event belongs to so
UIs group events by attempt. claim/spawned/complete/block/crash/
timeout/spawn_fail/gave_up/heartbeat events are all run-scoped;
created/promoted/assigned/edited stay task-scoped (run_id=NULL).
- Legacy DBs: migration adds the columns + indexes + synthesizes a
run row for any task that's 'running' before the runs table
existed, so subsequent complete/heartbeat/reclaim calls have a
target. Idempotent.
Structured handoff
- `complete_task(summary=, metadata=)` persists both on the closing
run. `summary` falls back to `result` when omitted so single-run
callers don't duplicate. `metadata` is a free-form dict
({changed_files, tests_run, findings, ...}).
- `build_worker_context` rewrites: now reads "Prior attempts on this
task" (closed runs: outcome, summary, error, metadata) and
"Parent task results" pulls run.summary + run.metadata of the
most-recent completed run per parent, falling back to task.result
for legacy rows without runs. Retrying workers see why earlier
attempts failed; downstream workers see parent handoffs
structurally, not as loose `result` strings.
CLI
- `hermes kanban complete <id> --summary "..." --metadata '{"files":1}'`.
JSON is parsed and rejected with exit-2 if malformed.
- New `hermes kanban runs <id> [--json]` verb. Shows per-run rows:
outcome, profile, elapsed, summary, error. JSON mode serializes
the full run dataclass for scripting.
Dashboard plugin
- GET /tasks/:id now carries a runs[] array alongside task / events /
comments / links. Each run serialised with outcome, summary,
metadata, worker_pid, elapsed fields.
- New Run History section in the drawer. Outcome-coloured left
border (green=active, blue=completed, amber=reclaimed,
red=crashed/timed_out/gave_up/blocked). Collapsed when >3 runs
with a '+N earlier' toggle. Shows summary + error + metadata
inline.
Forward-compat for v2 (vulcan's workflow templates + stages)
- `tasks.workflow_template_id` and `tasks.current_step_key` added as
nullable columns. v1 kernel ignores them for routing; v2 will add
workflow_templates + workflow_steps tables and wire the dispatcher
to consult them. task_runs has a matching `step_key` column. Lets
a v2 release land additively without another schema migration.
Tests (+22 in test_kanban_core_functionality.py, +2 in dashboard)
- run_created_on_claim / run_closed_on_complete_with_summary
- run_summary_falls_back_to_result
- multiple_attempts_preserved_as_runs (3 attempts: reclaimed →
crashed → completed, all visible in list_runs)
- run_on_block_with_reason / run_on_spawn_failure_records_failed_runs
(5 spawn_failed runs + 1 gave_up run)
- event_rows_carry_run_id (task-scoped vs run-scoped split)
- build_worker_context_includes_prior_attempts
- build_worker_context_uses_parent_run_summary (metadata JSON in context)
- migration_backfills_inflight_run_for_legacy_db (simulates a
pre-migration running task, re-runs init_db, asserts backfill)
- forward_compat_columns_writable
- cli_runs_verb + cli_runs_json
- cli_complete_with_summary_and_metadata (JSON round-trip through
shlex + argparse)
- cli_complete_bad_metadata_exits_nonzero
- task_detail_includes_runs / task_detail_runs_empty_before_claim
269/269 kanban suite pass under scripts/run_tests.sh. Live-smoke
covered: single-attempt complete → run closed + summary persisted;
retry scenario → two runs visible (blocked + completed); parent run
summary + metadata surfaced to child via build_worker_context;
forward-compat columns writable via UPDATE; GET /tasks/:id returns
runs[].
Docs
- New 'Runs — one row per attempt' section in kanban.md: the
why (full attempt history, structured metadata), the two-table
model (task is logical, run is execution), the structured handoff
shape (--summary / --metadata), example CLI + dashboard output,
forward-compat note for v2.
- Event reference updated to mention task_events.run_id.
- CLI reference gains 'hermes kanban runs <id>'.
Not in v1 (deferred to v2):
- Workflow templates (workflow_templates + workflow_steps tables,
stage-based routing, success/failure step links).
- 'stage' as a distinct axis from status in the UI.
- Shared-by-default workspace binding across stages of the same
workflow run.
- Pipeline replacement for the kanban-orchestrator skill (the
orchestrator's 'decompose, don't execute' guidance is still
correct; it becomes partly redundant once workflows land).
2026-04-27 06:54:19 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Runs surfacing (vulcan-artivus RFC feedback)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_task_detail_includes_runs(client):
|
|
|
|
|
"""GET /tasks/:id carries a runs[] array with the attempt history."""
|
|
|
|
|
r = client.post("/api/plugins/kanban/tasks",
|
|
|
|
|
json={"title": "port x", "assignee": "worker"}).json()
|
|
|
|
|
tid = r["task"]["id"]
|
|
|
|
|
|
|
|
|
|
# Drive status running to force a run creation: PATCH to running
|
|
|
|
|
# doesn't call claim_task (the PATCH path uses _set_status_direct),
|
|
|
|
|
# so use the bulk/claim indirection via the kernel.
|
|
|
|
|
import hermes_cli.kanban_db as _kb
|
|
|
|
|
conn = _kb.connect()
|
|
|
|
|
try:
|
|
|
|
|
_kb.claim_task(conn, tid)
|
|
|
|
|
_kb.complete_task(
|
|
|
|
|
conn, tid,
|
|
|
|
|
result="done",
|
|
|
|
|
summary="tested on rate limiter",
|
|
|
|
|
metadata={"changed_files": ["limiter.py"]},
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
d = client.get(f"/api/plugins/kanban/tasks/{tid}").json()
|
|
|
|
|
assert "runs" in d
|
|
|
|
|
assert len(d["runs"]) == 1
|
|
|
|
|
run = d["runs"][0]
|
|
|
|
|
assert run["outcome"] == "completed"
|
|
|
|
|
assert run["profile"] == "worker"
|
|
|
|
|
assert run["summary"] == "tested on rate limiter"
|
|
|
|
|
assert run["metadata"] == {"changed_files": ["limiter.py"]}
|
|
|
|
|
assert run["ended_at"] is not None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_task_detail_runs_empty_before_claim(client):
|
|
|
|
|
"""A task that's never been claimed has an empty runs[] list, not
|
|
|
|
|
a missing key."""
|
|
|
|
|
r = client.post("/api/plugins/kanban/tasks", json={"title": "fresh"}).json()
|
|
|
|
|
d = client.get(f"/api/plugins/kanban/tasks/{r['task']['id']}").json()
|
|
|
|
|
assert d["runs"] == []
|
fix(kanban): audit pass — close orphaned runs on archive / dashboard direct-status / drag-drop
Integration audit of the runs-as-first-class work (0146cb2bd) found five
bugs where structured runs got orphaned or dashboard parity was missing.
All behavioral fixes; no schema change needed.
Kernel
- archive_task: when called on a running task, now closes the
in-flight run with outcome='reclaimed' and clears current_run_id.
Previously, dashboard bulk-archive or CLI `kanban archive <running>`
would leave the task_runs row open with ended_at=NULL forever and
strand the pointer. Adds the claim_lock / claim_expires / worker_pid
clearing to the UPDATE so the task row is clean too.
- complete_task: embeds the first-line handoff summary in the
`completed` event payload (capped at 400 chars). Notifier can now
render `✔ task done — <title>\n<summary>` without a second SQL hit,
and the full summary still lives on the run row.
Dashboard plugin
- _set_status_direct: drag-drop OFF 'running' (to 'ready', 'todo',
'triage', 'done' — anywhere except back to 'running') now closes
the active run with outcome='reclaimed'. Clears worker_pid too.
Snapshots previous status + current_run_id before the UPDATE so
the decision has the right before-state. status event rows now
carry run_id when closing a run, NULL otherwise.
- UpdateTaskBody: adds `summary` and `metadata` fields. PATCH
/tasks/:id with status='done' now forwards them to complete_task,
giving the dashboard parity with `hermes kanban complete --summary
... --metadata ...`. Previously these fields only existed on the
CLI.
CLI
- `hermes kanban complete a b c --summary X` or `--metadata Y`:
refused with a clear stderr message instead of silently applying
the same handoff to every task. Bulk-close without handoff flags
still works. (Note: hermes_cli.main discards subcommand exit
codes via `args.func(args)` without propagating; tracked
separately. Side-effect check is the real guard.)
Gateway notifier
- Completion message prefers run.summary (carried in event payload)
over task.result. task.result remains the fallback for legacy rows
written before runs shipped.
- Docstring: renamed stale `spawn_auto_blocked` reference to
`gave_up` / `timed_out` — matches the actual TERMINAL_KINDS
tuple, which was already correct in code.
Tests (+8 in core functionality, +3 in dashboard plugin)
- archive_of_running_task_closes_run
- archive_of_ready_task_does_not_create_spurious_run
- dashboard_direct_status_change_off_running_closes_run
- dashboard_direct_status_change_within_same_state_is_noop_for_runs
- cli_bulk_complete_with_summary_rejects (side-effect assertion)
- cli_bulk_complete_without_summary_still_works
- completed_event_payload_carries_summary
- completed_event_payload_summary_none_when_missing
- patch_status_done_with_summary_and_metadata
- patch_status_done_without_summary_still_works (legacy path)
- patch_status_archive_closes_running_run (E2E through FastAPI TestClient)
164/164 kanban suite pass under scripts/run_tests.sh. Live smoke
(execute_code with isolated HERMES_HOME) covered all five fixed paths
plus a re-claim-after-drag-drop to confirm the fresh run is tracked
correctly after the orphan close.
2026-04-27 07:44:39 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_patch_status_done_with_summary_and_metadata(client):
|
|
|
|
|
"""PATCH /tasks/:id with status=done + summary + metadata must
|
|
|
|
|
reach complete_task, so the dashboard has CLI parity."""
|
|
|
|
|
# Create + claim.
|
|
|
|
|
r = client.post("/api/plugins/kanban/tasks", json={"title": "x", "assignee": "worker"})
|
|
|
|
|
tid = r.json()["task"]["id"]
|
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
|
|
|
conn = kb.connect()
|
|
|
|
|
try:
|
|
|
|
|
kb.claim_task(conn, tid)
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
r = client.patch(
|
|
|
|
|
f"/api/plugins/kanban/tasks/{tid}",
|
|
|
|
|
json={
|
|
|
|
|
"status": "done",
|
|
|
|
|
"summary": "shipped the thing",
|
|
|
|
|
"metadata": {"changed_files": ["a.py", "b.py"], "tests_run": 7},
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
|
|
|
|
|
# The run must have the summary + metadata attached.
|
|
|
|
|
conn = kb.connect()
|
|
|
|
|
try:
|
|
|
|
|
run = kb.latest_run(conn, tid)
|
|
|
|
|
assert run.outcome == "completed"
|
|
|
|
|
assert run.summary == "shipped the thing"
|
|
|
|
|
assert run.metadata == {"changed_files": ["a.py", "b.py"], "tests_run": 7}
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_patch_status_done_without_summary_still_works(client):
|
|
|
|
|
"""Back-compat: PATCH without the new fields still completes."""
|
|
|
|
|
r = client.post("/api/plugins/kanban/tasks", json={"title": "y", "assignee": "worker"})
|
|
|
|
|
tid = r.json()["task"]["id"]
|
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
|
|
|
conn = kb.connect()
|
|
|
|
|
try:
|
|
|
|
|
kb.claim_task(conn, tid)
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
r = client.patch(
|
|
|
|
|
f"/api/plugins/kanban/tasks/{tid}",
|
|
|
|
|
json={"status": "done", "result": "legacy shape"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
conn = kb.connect()
|
|
|
|
|
try:
|
|
|
|
|
run = kb.latest_run(conn, tid)
|
|
|
|
|
assert run.outcome == "completed"
|
|
|
|
|
assert run.summary == "legacy shape" # falls back to result
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_patch_status_archive_closes_running_run(client):
|
|
|
|
|
"""PATCH to archived while running must close the in-flight run."""
|
|
|
|
|
r = client.post("/api/plugins/kanban/tasks", json={"title": "z", "assignee": "worker"})
|
|
|
|
|
tid = r.json()["task"]["id"]
|
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
|
|
|
conn = kb.connect()
|
|
|
|
|
try:
|
|
|
|
|
kb.claim_task(conn, tid)
|
|
|
|
|
open_run = kb.latest_run(conn, tid)
|
|
|
|
|
assert open_run.ended_at is None
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
r = client.patch(
|
|
|
|
|
f"/api/plugins/kanban/tasks/{tid}",
|
|
|
|
|
json={"status": "archived"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == 200, r.text
|
|
|
|
|
conn = kb.connect()
|
|
|
|
|
try:
|
|
|
|
|
task = kb.get_task(conn, tid)
|
|
|
|
|
assert task.status == "archived"
|
|
|
|
|
assert task.current_run_id is None
|
|
|
|
|
assert kb.latest_run(conn, tid).outcome == "reclaimed"
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
fix(kanban): deep-scan pass 2 — synthetic runs, event.run_id plumbing, invariant recovery, live drawer refresh
Second integration audit covering surfaces the first pass didn't hit.
Found eight issues spanning kernel, dashboard frontend, notifier, and CLI.
All behavioral / UX fixes; no schema change.
Kernel
- complete_task on a never-claimed task (ready/blocked → done with no
run in flight) was silently dropping the summary/metadata/result
onto a non-existent run. Now synthesizes a zero-duration run
(started_at == ended_at) so attempt history is complete. Only
fires when there's actually handoff data to persist — bare
complete_task(tid) remains a no-op for run creation.
- block_task on a never-claimed task had the same bug for --reason.
Same fix: synthesize a zero-duration run when a reason is passed.
- Event dataclass gained a `run_id: Optional[int] = None` field.
list_events, unseen_events_for_sub, and the dashboard _event_dict
were all SELECTing the column but dropping it on the way out,
so downstream consumers couldn't group events by attempt. Every
read path now surfaces run_id.
- claim_task got a defensive invariant-recovery step: if somehow
`current_run_id` is non-NULL on a task in 'ready' status (invariant
violation from an unknown code path), close the leaked run as
'reclaimed' inside the same txn as the new claim. No-op in the
common case; belt-and-suspenders in case a future code path forgets
to clear the pointer.
Dashboard
- GET /tasks/:id events array now carries run_id per event (via
_event_dict).
- WebSocket /events SELECT now includes run_id in the pushed event
payload.
- TaskDrawer reloads itself on live events for its own task id. New
`taskEventTick[taskId]` state in the Board, incremented on every
WS event, passed down as `eventTick` prop; drawer's useEffect
depends on it. Previously, background workers completing a task
the user was viewing left the drawer showing stale data until
manual close/reopen.
- CSS: added `.hermes-kanban-run--ended` rule for the fallback class
the JS emits when outcome is unset. Harmless before; just
inconsistent.
CLI
- `hermes kanban watch --kinds` help text listed the legacy event
name `spawn_auto_blocked`. The kernel migration renames it to
`gave_up`, so users typing the documented name got zero matches.
Now shows the current lexicon (`completed,blocked,gave_up,
crashed,timed_out`).
Tests (+6 in core functionality, +1 in dashboard plugin)
- complete_never_claimed_task_synthesizes_run
- block_never_claimed_task_synthesizes_run
- complete_never_claimed_without_handoff_skips_synthesis
- event_dataclass_carries_run_id (created.run_id None, completed.run_id matches)
- unseen_events_for_sub_includes_run_id (notifier path)
- claim_task_recovers_from_invariant_leak (engineer the leak, verify recovery)
- event_dict_includes_run_id (dashboard API shape)
171/171 kanban suite pass under scripts/run_tests.sh. Live-smoke (isolated
HERMES_HOME via execute_code) exercised all six fixed paths plus the
claim-after-leak recovery sequence.
Docs
- Runs section: new 'Synthetic runs for never-claimed completions'
and 'Live drawer refresh' paragraphs explaining the invariants.
- Event reference: `created` / `promoted` / `unblocked` entries now
explicitly note `run_id` is `NULL`; `completed` / `blocked`
describe synthetic-run fallback.
2026-04-27 19:23:49 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_event_dict_includes_run_id(client):
|
|
|
|
|
"""GET /tasks/:id returns events with run_id populated."""
|
|
|
|
|
r = client.post("/api/plugins/kanban/tasks", json={"title": "e", "assignee": "worker"})
|
|
|
|
|
tid = r.json()["task"]["id"]
|
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
|
|
|
conn = kb.connect()
|
|
|
|
|
try:
|
|
|
|
|
kb.claim_task(conn, tid)
|
|
|
|
|
run_id = kb.latest_run(conn, tid).id
|
|
|
|
|
kb.complete_task(conn, tid, summary="wss")
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
r = client.get(f"/api/plugins/kanban/tasks/{tid}")
|
|
|
|
|
assert r.status_code == 200
|
|
|
|
|
events = r.json()["events"]
|
|
|
|
|
# Every event in the response must have a run_id key (None or int).
|
|
|
|
|
for e in events:
|
|
|
|
|
assert "run_id" in e, f"missing run_id in event: {e}"
|
|
|
|
|
# completed event must have the actual run_id.
|
|
|
|
|
comp = [e for e in events if e["kind"] == "completed"]
|
|
|
|
|
assert comp[0]["run_id"] == run_id
|