@bitseek/hermes-webui 0.1.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +213 -0
- package/bin/hermes-webui.mjs +588 -0
- package/package.json +25 -0
- package/scripts/sync-vendor.mjs +74 -0
- package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
- package/templates/systemd/hermes-webui.service +13 -0
- package/templates/windows/hermes-webui-task.ps1 +3 -0
- package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
- package/vendor/agent-frontend-shell/.dockerignore +7 -0
- package/vendor/agent-frontend-shell/.env.docker.example +89 -0
- package/vendor/agent-frontend-shell/.env.example +34 -0
- package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
- package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
- package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
- package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
- package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
- package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
- package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
- package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
- package/vendor/agent-frontend-shell/AGENTS.md +80 -0
- package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
- package/vendor/agent-frontend-shell/BUGS.md +52 -0
- package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
- package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
- package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
- package/vendor/agent-frontend-shell/DESIGN.md +173 -0
- package/vendor/agent-frontend-shell/Dockerfile +91 -0
- package/vendor/agent-frontend-shell/LICENSE +21 -0
- package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
- package/vendor/agent-frontend-shell/README.md +705 -0
- package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
- package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
- package/vendor/agent-frontend-shell/TESTING.md +1932 -0
- package/vendor/agent-frontend-shell/THEMES.md +170 -0
- package/vendor/agent-frontend-shell/api/__init__.py +1 -0
- package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
- package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
- package/vendor/agent-frontend-shell/api/auth.py +592 -0
- package/vendor/agent-frontend-shell/api/background.py +87 -0
- package/vendor/agent-frontend-shell/api/clarify.py +238 -0
- package/vendor/agent-frontend-shell/api/commands.py +124 -0
- package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
- package/vendor/agent-frontend-shell/api/config.py +5178 -0
- package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
- package/vendor/agent-frontend-shell/api/extensions.py +253 -0
- package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
- package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
- package/vendor/agent-frontend-shell/api/goals.py +608 -0
- package/vendor/agent-frontend-shell/api/helpers.py +474 -0
- package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
- package/vendor/agent-frontend-shell/api/metering.py +194 -0
- package/vendor/agent-frontend-shell/api/models.py +4210 -0
- package/vendor/agent-frontend-shell/api/oauth.py +770 -0
- package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
- package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
- package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
- package/vendor/agent-frontend-shell/api/providers.py +2175 -0
- package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
- package/vendor/agent-frontend-shell/api/rollback.py +320 -0
- package/vendor/agent-frontend-shell/api/routes.py +13990 -0
- package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
- package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
- package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
- package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
- package/vendor/agent-frontend-shell/api/session_events.py +45 -0
- package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
- package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
- package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
- package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
- package/vendor/agent-frontend-shell/api/startup.py +128 -0
- package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
- package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
- package/vendor/agent-frontend-shell/api/system_health.py +167 -0
- package/vendor/agent-frontend-shell/api/terminal.py +410 -0
- package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
- package/vendor/agent-frontend-shell/api/updates.py +1261 -0
- package/vendor/agent-frontend-shell/api/upload.py +322 -0
- package/vendor/agent-frontend-shell/api/usage.py +26 -0
- package/vendor/agent-frontend-shell/api/workspace.py +867 -0
- package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
- package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
- package/vendor/agent-frontend-shell/bootstrap.py +492 -0
- package/vendor/agent-frontend-shell/ctl.sh +427 -0
- package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
- package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
- package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
- package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
- package/vendor/agent-frontend-shell/docker_init.bash +459 -0
- package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
- package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
- package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
- package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
- package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
- package/vendor/agent-frontend-shell/docs/docker.md +337 -0
- package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
- package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
- package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
- package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
- package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
- package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
- package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
- package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
- package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
- package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
- package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
- package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
- package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
- package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
- package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
- package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
- package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
- package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
- package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
- package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
- package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
- package/vendor/agent-frontend-shell/extensions/index.js +67 -0
- package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
- package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
- package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
- package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
- package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
- package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
- package/vendor/agent-frontend-shell/install.sh +63 -0
- package/vendor/agent-frontend-shell/mcp_server.py +567 -0
- package/vendor/agent-frontend-shell/package.json +12 -0
- package/vendor/agent-frontend-shell/pyproject.toml +56 -0
- package/vendor/agent-frontend-shell/pytest.ini +3 -0
- package/vendor/agent-frontend-shell/requirements.txt +5 -0
- package/vendor/agent-frontend-shell/server.py +624 -0
- package/vendor/agent-frontend-shell/start.ps1 +210 -0
- package/vendor/agent-frontend-shell/start.sh +65 -0
- package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
- package/vendor/agent-frontend-shell/static/boot.js +1990 -0
- package/vendor/agent-frontend-shell/static/commands.js +1402 -0
- package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
- package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
- package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
- package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
- package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
- package/vendor/agent-frontend-shell/static/icons.js +92 -0
- package/vendor/agent-frontend-shell/static/index.html +1506 -0
- package/vendor/agent-frontend-shell/static/login.js +177 -0
- package/vendor/agent-frontend-shell/static/manifest.json +53 -0
- package/vendor/agent-frontend-shell/static/messages.js +3521 -0
- package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
- package/vendor/agent-frontend-shell/static/panels.js +7995 -0
- package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
- package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
- package/vendor/agent-frontend-shell/static/style.css +4774 -0
- package/vendor/agent-frontend-shell/static/sw.js +173 -0
- package/vendor/agent-frontend-shell/static/terminal.js +632 -0
- package/vendor/agent-frontend-shell/static/ui.js +8997 -0
- package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
- package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
- package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
- package/vendor/agent-frontend-shell/static/workspace.js +680 -0
|
@@ -0,0 +1,1255 @@
|
|
|
1
|
+
"""Hermes Kanban bridge for the WebUI.
|
|
2
|
+
|
|
3
|
+
This module exposes a full CRUD API under ``/api/kanban/*`` while keeping
|
|
4
|
+
Hermes Agent's ``hermes_cli.kanban_db`` as the only source of truth.
|
|
5
|
+
|
|
6
|
+
Supported operations:
|
|
7
|
+
- Task CRUD (create, read, patch, bulk update, archive)
|
|
8
|
+
- Multi-board management (list, create, archive, switch)
|
|
9
|
+
- Task dependency links (create, delete)
|
|
10
|
+
- SSE live event stream for real-time updates
|
|
11
|
+
- Comments and worker dispatch integration
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import time
|
|
18
|
+
from dataclasses import asdict, is_dataclass
|
|
19
|
+
from urllib.parse import parse_qs, unquote
|
|
20
|
+
|
|
21
|
+
from api.helpers import bad, j
|
|
22
|
+
|
|
23
|
+
BOARD_COLUMNS = ["triage", "todo", "ready", "running", "blocked", "done"]
|
|
24
|
+
_TASK_PREFIX = "/api/kanban/tasks/"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _kb():
|
|
28
|
+
from hermes_cli import kanban_db as kb
|
|
29
|
+
|
|
30
|
+
return kb
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _resolve_board(parsed):
|
|
34
|
+
"""Validate and normalise a ?board=<slug> query param.
|
|
35
|
+
|
|
36
|
+
Returns the normalised slug, or ``None`` when the caller omitted the
|
|
37
|
+
param. Raises ValueError on a malformed slug so the bridge surfaces a
|
|
38
|
+
clean 400 instead of a 500 from deeper in the library.
|
|
39
|
+
"""
|
|
40
|
+
raw = (parse_qs(parsed.query or "").get("board") or [None])[0]
|
|
41
|
+
return _normalise_board_or_raise(raw)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resolve_board_from_body(body):
|
|
45
|
+
"""Same contract as :func:`_resolve_board` but reads ``board`` from a
|
|
46
|
+
parsed JSON body (POST / PATCH / DELETE handlers receive a dict, not
|
|
47
|
+
a parsed URL). Returns ``None`` when the body did not specify a board.
|
|
48
|
+
"""
|
|
49
|
+
if not isinstance(body, dict):
|
|
50
|
+
return None
|
|
51
|
+
raw = body.get("board")
|
|
52
|
+
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
|
53
|
+
return None
|
|
54
|
+
return _normalise_board_or_raise(raw)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _normalise_board_or_raise(raw):
|
|
58
|
+
"""Shared normalisation + existence check for board slugs."""
|
|
59
|
+
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
|
|
60
|
+
return None
|
|
61
|
+
kb = _kb()
|
|
62
|
+
try:
|
|
63
|
+
normed = kb._normalize_board_slug(raw)
|
|
64
|
+
except (ValueError, AttributeError) as exc:
|
|
65
|
+
raise ValueError(f"invalid board slug: {raw!r}") from exc
|
|
66
|
+
if not normed:
|
|
67
|
+
return None
|
|
68
|
+
# Allow the default board even if it has not been materialised yet
|
|
69
|
+
# (kb.init_db will create it lazily). For non-default boards, require
|
|
70
|
+
# the directory exists or _conn would fail with a confusing OperationalError.
|
|
71
|
+
try:
|
|
72
|
+
default_slug = getattr(kb, "DEFAULT_BOARD", "default")
|
|
73
|
+
except Exception:
|
|
74
|
+
default_slug = "default"
|
|
75
|
+
if normed != default_slug and not kb.board_exists(normed):
|
|
76
|
+
raise LookupError(f"board {normed!r} does not exist")
|
|
77
|
+
return normed
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _conn(board=None):
|
|
81
|
+
kb = _kb()
|
|
82
|
+
kb.init_db(board=board)
|
|
83
|
+
return kb.connect(board=board)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _obj_dict(value):
|
|
87
|
+
if value is None:
|
|
88
|
+
return None
|
|
89
|
+
if is_dataclass(value):
|
|
90
|
+
return asdict(value)
|
|
91
|
+
if isinstance(value, dict):
|
|
92
|
+
return dict(value)
|
|
93
|
+
return dict(getattr(value, "__dict__", {}))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _task_dict(task):
|
|
97
|
+
data = _obj_dict(task)
|
|
98
|
+
if not data:
|
|
99
|
+
return data
|
|
100
|
+
try:
|
|
101
|
+
age = _kb().task_age(task)
|
|
102
|
+
except Exception:
|
|
103
|
+
age = None
|
|
104
|
+
data["age_seconds"] = age
|
|
105
|
+
data["age"] = age
|
|
106
|
+
data.setdefault("progress", None)
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _latest_event_id(conn) -> int:
|
|
111
|
+
try:
|
|
112
|
+
row = conn.execute("SELECT COALESCE(MAX(id), 0) AS latest FROM task_events").fetchone()
|
|
113
|
+
return int(row["latest"] or 0)
|
|
114
|
+
except Exception:
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _bool_query(parsed, name: str, default: bool = False) -> bool:
|
|
119
|
+
raw = (parse_qs(parsed.query or "").get(name) or [None])[0]
|
|
120
|
+
if raw is None:
|
|
121
|
+
return default
|
|
122
|
+
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _str_query(parsed, name: str):
|
|
126
|
+
raw = (parse_qs(parsed.query or "").get(name) or [None])[0]
|
|
127
|
+
return str(raw).strip() or None if raw is not None else None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _int_query(parsed, name: str, default=None, *, minimum=None, maximum=None):
|
|
131
|
+
raw = _str_query(parsed, name)
|
|
132
|
+
if raw is None:
|
|
133
|
+
return default
|
|
134
|
+
try:
|
|
135
|
+
value = int(raw)
|
|
136
|
+
except (TypeError, ValueError):
|
|
137
|
+
return default
|
|
138
|
+
if minimum is not None:
|
|
139
|
+
value = max(minimum, value)
|
|
140
|
+
if maximum is not None:
|
|
141
|
+
value = min(maximum, value)
|
|
142
|
+
return value
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _task_link_counts(conn, tasks):
|
|
146
|
+
counts = {task.id: {"parents": 0, "children": 0} for task in tasks}
|
|
147
|
+
try:
|
|
148
|
+
rows = conn.execute("SELECT parent_id, child_id FROM task_links").fetchall()
|
|
149
|
+
except Exception:
|
|
150
|
+
return counts
|
|
151
|
+
for row in rows:
|
|
152
|
+
counts.setdefault(row["parent_id"], {"parents": 0, "children": 0})["children"] += 1
|
|
153
|
+
counts.setdefault(row["child_id"], {"parents": 0, "children": 0})["parents"] += 1
|
|
154
|
+
return counts
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _comment_counts(conn):
|
|
158
|
+
try:
|
|
159
|
+
rows = conn.execute(
|
|
160
|
+
"SELECT task_id, COUNT(*) AS n FROM task_comments GROUP BY task_id"
|
|
161
|
+
).fetchall()
|
|
162
|
+
except Exception:
|
|
163
|
+
return {}
|
|
164
|
+
return {row["task_id"]: int(row["n"] or 0) for row in rows}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _board_payload(parsed):
|
|
168
|
+
board = _resolve_board(parsed)
|
|
169
|
+
kb = _kb()
|
|
170
|
+
tenant = _str_query(parsed, "tenant")
|
|
171
|
+
assignee = _str_query(parsed, "assignee")
|
|
172
|
+
include_archived = _bool_query(parsed, "include_archived", False)
|
|
173
|
+
only_mine = _bool_query(parsed, "only_mine", False)
|
|
174
|
+
since = _int_query(parsed, "since", None, minimum=0)
|
|
175
|
+
profile = None
|
|
176
|
+
if only_mine and not assignee:
|
|
177
|
+
try:
|
|
178
|
+
from api.profiles import get_active_profile_name
|
|
179
|
+
|
|
180
|
+
profile = get_active_profile_name() or "default"
|
|
181
|
+
except Exception:
|
|
182
|
+
profile = "default"
|
|
183
|
+
assignee = profile
|
|
184
|
+
|
|
185
|
+
with _conn(board=board) as conn:
|
|
186
|
+
latest_event_id = _latest_event_id(conn)
|
|
187
|
+
if since is not None and since >= latest_event_id:
|
|
188
|
+
return {"changed": False, "latest_event_id": latest_event_id, "read_only": False}
|
|
189
|
+
|
|
190
|
+
tasks = kb.list_tasks(
|
|
191
|
+
conn,
|
|
192
|
+
tenant=tenant,
|
|
193
|
+
assignee=assignee,
|
|
194
|
+
include_archived=include_archived,
|
|
195
|
+
)
|
|
196
|
+
link_counts = _task_link_counts(conn, tasks)
|
|
197
|
+
comment_counts = _comment_counts(conn)
|
|
198
|
+
|
|
199
|
+
def row(task):
|
|
200
|
+
data = _task_dict(task)
|
|
201
|
+
data["link_counts"] = link_counts.get(task.id, {"parents": 0, "children": 0})
|
|
202
|
+
data["comment_count"] = comment_counts.get(task.id, 0)
|
|
203
|
+
return data
|
|
204
|
+
|
|
205
|
+
columns = [
|
|
206
|
+
{"name": name, "tasks": [row(task) for task in tasks if task.status == name]}
|
|
207
|
+
for name in BOARD_COLUMNS
|
|
208
|
+
]
|
|
209
|
+
if include_archived:
|
|
210
|
+
columns.append({
|
|
211
|
+
"name": "archived",
|
|
212
|
+
"tasks": [row(task) for task in tasks if task.status == "archived"],
|
|
213
|
+
})
|
|
214
|
+
return {
|
|
215
|
+
"columns": columns,
|
|
216
|
+
"tenants": sorted({task.tenant for task in tasks if getattr(task, "tenant", None)}),
|
|
217
|
+
"assignees": sorted({task.assignee for task in tasks if getattr(task, "assignee", None)}),
|
|
218
|
+
"latest_event_id": latest_event_id,
|
|
219
|
+
"changed": True,
|
|
220
|
+
"read_only": False,
|
|
221
|
+
"filters": {
|
|
222
|
+
"tenant": tenant,
|
|
223
|
+
"assignee": assignee,
|
|
224
|
+
"include_archived": include_archived,
|
|
225
|
+
"only_mine": only_mine,
|
|
226
|
+
"profile": profile,
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _validate_status(status: str) -> str:
|
|
233
|
+
value = str(status or "").strip().lower()
|
|
234
|
+
allowed = set(BOARD_COLUMNS) | {"archived"}
|
|
235
|
+
if value not in allowed:
|
|
236
|
+
raise ValueError(f"invalid status: {value}")
|
|
237
|
+
return value
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _set_status_direct(conn, task_id: str, new_status: str) -> bool:
|
|
241
|
+
"""Direct status write for drag-drop moves not covered by structured verbs.
|
|
242
|
+
|
|
243
|
+
Used for ``todo <-> ready`` and ``running -> ready`` transitions. The
|
|
244
|
+
structured verbs (``complete_task``, ``block_task``, ``unblock_task``,
|
|
245
|
+
``archive_task``, ``claim_task``) own their own state changes; this helper
|
|
246
|
+
handles the remainder while preserving the dispatcher's contract:
|
|
247
|
+
|
|
248
|
+
- When transitioning OFF ``running`` to anything other than the terminal
|
|
249
|
+
verbs, claim_lock / claim_expires / worker_pid are nulled so the
|
|
250
|
+
dispatcher doesn't see a phantom-running task. The active run (if any)
|
|
251
|
+
is closed with ``outcome='reclaimed'`` so attempt history isn't
|
|
252
|
+
orphaned.
|
|
253
|
+
- When transitioning INTO ``running``, claim fields are preserved (this
|
|
254
|
+
function is NOT used for entering 'running' — that goes through
|
|
255
|
+
``kb.claim_task()`` and the bridge rejects raw 'running' status writes
|
|
256
|
+
with HTTP 400).
|
|
257
|
+
|
|
258
|
+
Mirrors the agent dashboard plugin's ``_set_status_direct``
|
|
259
|
+
(plugins/kanban/dashboard/plugin_api.py) so first-party clients see
|
|
260
|
+
identical behaviour from either surface.
|
|
261
|
+
"""
|
|
262
|
+
kb = _kb()
|
|
263
|
+
with kb.write_txn(conn):
|
|
264
|
+
prev = conn.execute(
|
|
265
|
+
"SELECT status, current_run_id FROM tasks WHERE id = ?",
|
|
266
|
+
(task_id,),
|
|
267
|
+
).fetchone()
|
|
268
|
+
if prev is None:
|
|
269
|
+
return False
|
|
270
|
+
was_running = prev["status"] == "running"
|
|
271
|
+
cur = conn.execute(
|
|
272
|
+
"UPDATE tasks SET status = ?, "
|
|
273
|
+
" claim_lock = CASE WHEN ? = 'running' THEN claim_lock ELSE NULL END, "
|
|
274
|
+
" claim_expires = CASE WHEN ? = 'running' THEN claim_expires ELSE NULL END, "
|
|
275
|
+
" worker_pid = CASE WHEN ? = 'running' THEN worker_pid ELSE NULL END "
|
|
276
|
+
"WHERE id = ?",
|
|
277
|
+
(new_status, new_status, new_status, new_status, task_id),
|
|
278
|
+
)
|
|
279
|
+
if cur.rowcount != 1:
|
|
280
|
+
return False
|
|
281
|
+
run_id = None
|
|
282
|
+
if was_running and new_status != "running" and prev["current_run_id"]:
|
|
283
|
+
try:
|
|
284
|
+
run_id = kb._end_run(
|
|
285
|
+
conn, task_id,
|
|
286
|
+
outcome="reclaimed", status="reclaimed",
|
|
287
|
+
summary=f"status changed to {new_status} (webui/direct)",
|
|
288
|
+
)
|
|
289
|
+
except Exception:
|
|
290
|
+
# _end_run is best-effort here; the status flip itself is
|
|
291
|
+
# what matters for sidebar rendering.
|
|
292
|
+
run_id = None
|
|
293
|
+
conn.execute(
|
|
294
|
+
"INSERT INTO task_events (task_id, run_id, kind, payload, created_at) "
|
|
295
|
+
"VALUES (?, ?, 'status', ?, ?)",
|
|
296
|
+
(task_id, run_id, json.dumps({"status": new_status, "source": "webui"}), int(time.time())),
|
|
297
|
+
)
|
|
298
|
+
if new_status in ("done", "ready") and hasattr(kb, "recompute_ready"):
|
|
299
|
+
try:
|
|
300
|
+
kb.recompute_ready(conn)
|
|
301
|
+
except Exception:
|
|
302
|
+
pass
|
|
303
|
+
return True
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _create_task_payload(body: dict, *, board=None):
|
|
307
|
+
title = str(body.get("title") or "").strip()
|
|
308
|
+
if not title:
|
|
309
|
+
raise ValueError("title is required")
|
|
310
|
+
try:
|
|
311
|
+
priority = int(body.get("priority") or 0)
|
|
312
|
+
except (TypeError, ValueError):
|
|
313
|
+
raise ValueError("priority must be an integer")
|
|
314
|
+
kb = _kb()
|
|
315
|
+
requested_status = body.get("status")
|
|
316
|
+
with _conn(board=board) as conn:
|
|
317
|
+
task_id = kb.create_task(
|
|
318
|
+
conn,
|
|
319
|
+
title=title,
|
|
320
|
+
body=body.get("body") or None,
|
|
321
|
+
assignee=body.get("assignee") or None,
|
|
322
|
+
created_by=body.get("created_by") or "webui",
|
|
323
|
+
tenant=body.get("tenant") or None,
|
|
324
|
+
priority=priority,
|
|
325
|
+
parents=body.get("parents") or (),
|
|
326
|
+
triage=bool(body.get("triage") or False),
|
|
327
|
+
workspace_kind=body.get("workspace_kind") or "scratch",
|
|
328
|
+
workspace_path=body.get("workspace_path") or None,
|
|
329
|
+
idempotency_key=body.get("idempotency_key") or None,
|
|
330
|
+
max_runtime_seconds=body.get("max_runtime_seconds") or None,
|
|
331
|
+
skills=body.get("skills") or None,
|
|
332
|
+
)
|
|
333
|
+
if requested_status:
|
|
334
|
+
_patch_task(conn, task_id, {"status": requested_status})
|
|
335
|
+
return {"task": _task_dict(kb.get_task(conn, task_id)), "read_only": False}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _patch_task(conn, task_id: str, body: dict):
|
|
339
|
+
kb = _kb()
|
|
340
|
+
task = kb.get_task(conn, task_id)
|
|
341
|
+
if not task:
|
|
342
|
+
raise LookupError("task not found")
|
|
343
|
+
|
|
344
|
+
updates = {}
|
|
345
|
+
if "title" in body:
|
|
346
|
+
title = str(body.get("title") or "").strip()
|
|
347
|
+
if not title:
|
|
348
|
+
raise ValueError("title is required")
|
|
349
|
+
updates["title"] = title
|
|
350
|
+
if "body" in body:
|
|
351
|
+
updates["body"] = body.get("body") or None
|
|
352
|
+
if "tenant" in body:
|
|
353
|
+
updates["tenant"] = body.get("tenant") or None
|
|
354
|
+
if "priority" in body:
|
|
355
|
+
try:
|
|
356
|
+
updates["priority"] = int(body.get("priority") or 0)
|
|
357
|
+
except (TypeError, ValueError):
|
|
358
|
+
raise ValueError("priority must be an integer")
|
|
359
|
+
|
|
360
|
+
for field, value in updates.items():
|
|
361
|
+
if hasattr(task, field):
|
|
362
|
+
try:
|
|
363
|
+
setattr(task, field, value)
|
|
364
|
+
except Exception:
|
|
365
|
+
pass
|
|
366
|
+
if updates:
|
|
367
|
+
assignments = ", ".join(f"{field} = ?" for field in updates)
|
|
368
|
+
conn.execute(f"UPDATE tasks SET {assignments} WHERE id = ?", [*updates.values(), task_id])
|
|
369
|
+
if hasattr(kb, "_append_event"):
|
|
370
|
+
kb._append_event(conn, task_id, "updated", {"fields": list(updates), "source": "webui"})
|
|
371
|
+
|
|
372
|
+
if "assignee" in body:
|
|
373
|
+
if not kb.assign_task(conn, task_id, body.get("assignee") or None):
|
|
374
|
+
raise LookupError("task not found")
|
|
375
|
+
|
|
376
|
+
if "status" not in body or body.get("status") in (None, ""):
|
|
377
|
+
return
|
|
378
|
+
status = _validate_status(body.get("status"))
|
|
379
|
+
if status == "done":
|
|
380
|
+
if not kb.complete_task(conn, task_id, result=body.get("result"), summary=body.get("summary")):
|
|
381
|
+
raise LookupError("task not found")
|
|
382
|
+
elif status == "blocked":
|
|
383
|
+
if not kb.block_task(conn, task_id, reason=body.get("block_reason") or body.get("reason")):
|
|
384
|
+
raise LookupError("task not found")
|
|
385
|
+
elif status == "archived":
|
|
386
|
+
if not kb.archive_task(conn, task_id):
|
|
387
|
+
raise LookupError("task not found")
|
|
388
|
+
elif status == "running":
|
|
389
|
+
# The 'running' state is owned by the kanban dispatcher / claim
|
|
390
|
+
# protocol — entering it via raw UPDATE bypasses claim_lock,
|
|
391
|
+
# claim_expires, started_at, and worker_pid, which leaves the task
|
|
392
|
+
# in a state the dispatcher treats as "phantom claimed" and may
|
|
393
|
+
# reclaim or hide. Match the agent dashboard plugin's contract
|
|
394
|
+
# (plugins/kanban/dashboard/plugin_api.py update_task) by rejecting
|
|
395
|
+
# this transition with HTTP 400. Workers enter 'running' via
|
|
396
|
+
# kb.claim_task(); UI users should use the dispatcher nudge.
|
|
397
|
+
raise ValueError(
|
|
398
|
+
"Cannot set status to 'running' directly; use the dispatcher/claim path"
|
|
399
|
+
)
|
|
400
|
+
elif status == "ready":
|
|
401
|
+
# If the task is currently 'blocked', use the structured unblock
|
|
402
|
+
# verb so the unblocked event fires. Otherwise it's a legitimate
|
|
403
|
+
# drag-drop or click move (e.g. todo → ready, running → ready when
|
|
404
|
+
# the user yanks a stuck worker back to the queue) and we use the
|
|
405
|
+
# claim-aware direct status write.
|
|
406
|
+
current = kb.get_task(conn, task_id)
|
|
407
|
+
if not current:
|
|
408
|
+
raise LookupError("task not found")
|
|
409
|
+
if current.status == "blocked":
|
|
410
|
+
if not kb.unblock_task(conn, task_id):
|
|
411
|
+
raise LookupError("task not found")
|
|
412
|
+
else:
|
|
413
|
+
if not _set_status_direct(conn, task_id, "ready"):
|
|
414
|
+
raise LookupError("task not found")
|
|
415
|
+
elif status in ("triage", "todo"):
|
|
416
|
+
# Direct status write for drag-drop moves between non-running,
|
|
417
|
+
# non-terminal columns. Uses the claim-aware helper that nulls out
|
|
418
|
+
# claim_lock / claim_expires / worker_pid when leaving 'running'
|
|
419
|
+
# and ends any active run with outcome='reclaimed'.
|
|
420
|
+
if not _set_status_direct(conn, task_id, status):
|
|
421
|
+
raise LookupError("task not found")
|
|
422
|
+
else:
|
|
423
|
+
# _validate_status guarantees we never reach here, but be defensive.
|
|
424
|
+
raise ValueError(f"unknown status: {status}")
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _patch_task_payload(task_id: str, body: dict, *, board=None):
|
|
428
|
+
task_id = str(task_id or "").strip()
|
|
429
|
+
if not task_id:
|
|
430
|
+
raise ValueError("task_id is required")
|
|
431
|
+
kb = _kb()
|
|
432
|
+
with _conn(board=board) as conn:
|
|
433
|
+
_patch_task(conn, task_id, body)
|
|
434
|
+
return {"task": _task_dict(kb.get_task(conn, task_id)), "read_only": False}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _comment_payload(task_id: str, body: dict, *, board=None):
|
|
438
|
+
task_id = str(task_id or "").strip()
|
|
439
|
+
comment_body = str(body.get("body") or "").strip()
|
|
440
|
+
if not task_id:
|
|
441
|
+
raise ValueError("task_id is required")
|
|
442
|
+
if not comment_body:
|
|
443
|
+
raise ValueError("body is required")
|
|
444
|
+
kb = _kb()
|
|
445
|
+
with _conn(board=board) as conn:
|
|
446
|
+
if not kb.get_task(conn, task_id):
|
|
447
|
+
raise LookupError("task not found")
|
|
448
|
+
comment_id = kb.add_comment(conn, task_id, body.get("author") or "webui", comment_body)
|
|
449
|
+
return {"ok": True, "comment_id": comment_id, "read_only": False}
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _link_tasks_payload(body: dict, *, unlink: bool = False, board=None):
|
|
453
|
+
parent_id = str(body.get("parent_id") or "").strip()
|
|
454
|
+
child_id = str(body.get("child_id") or "").strip()
|
|
455
|
+
if not parent_id or not child_id:
|
|
456
|
+
raise ValueError("parent_id and child_id are required")
|
|
457
|
+
kb = _kb()
|
|
458
|
+
with _conn(board=board) as conn:
|
|
459
|
+
if not kb.get_task(conn, parent_id):
|
|
460
|
+
raise LookupError("parent task not found")
|
|
461
|
+
if not kb.get_task(conn, child_id):
|
|
462
|
+
raise LookupError("child task not found")
|
|
463
|
+
if unlink:
|
|
464
|
+
changed = kb.unlink_tasks(conn, parent_id, child_id)
|
|
465
|
+
return {"ok": True, "changed": bool(changed), "parent_id": parent_id, "child_id": child_id, "read_only": False}
|
|
466
|
+
kb.link_tasks(conn, parent_id, child_id)
|
|
467
|
+
return {"ok": True, "parent_id": parent_id, "child_id": child_id, "read_only": False}
|
|
468
|
+
|
|
469
|
+
def _links_for(conn, task_id: str) -> dict:
|
|
470
|
+
kb = _kb()
|
|
471
|
+
return {
|
|
472
|
+
"parents": kb.parent_ids(conn, task_id),
|
|
473
|
+
"children": kb.child_ids(conn, task_id),
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _task_detail_payload(task_id: str, *, board=None):
|
|
478
|
+
kb = _kb()
|
|
479
|
+
with _conn(board=board) as conn:
|
|
480
|
+
task = kb.get_task(conn, task_id)
|
|
481
|
+
if not task:
|
|
482
|
+
return None
|
|
483
|
+
return {
|
|
484
|
+
"task": _task_dict(task),
|
|
485
|
+
"comments": [_obj_dict(c) for c in kb.list_comments(conn, task_id)],
|
|
486
|
+
"events": [_obj_dict(e) for e in kb.list_events(conn, task_id)],
|
|
487
|
+
"links": _links_for(conn, task_id),
|
|
488
|
+
"runs": [_obj_dict(r) for r in kb.list_runs(conn, task_id)],
|
|
489
|
+
"read_only": False,
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _events_payload(parsed):
|
|
494
|
+
board = _resolve_board(parsed)
|
|
495
|
+
since = _int_query(parsed, "since", 0, minimum=0)
|
|
496
|
+
limit = _int_query(parsed, "limit", 200, minimum=1, maximum=200)
|
|
497
|
+
with _conn(board=board) as conn:
|
|
498
|
+
rows = conn.execute(
|
|
499
|
+
"SELECT id, task_id, run_id, kind, payload, created_at "
|
|
500
|
+
"FROM task_events WHERE id > ? ORDER BY id ASC LIMIT ?",
|
|
501
|
+
(since, limit),
|
|
502
|
+
).fetchall()
|
|
503
|
+
events = []
|
|
504
|
+
cursor = since
|
|
505
|
+
for row in rows:
|
|
506
|
+
try:
|
|
507
|
+
payload = json.loads(row["payload"]) if row["payload"] else None
|
|
508
|
+
except Exception:
|
|
509
|
+
payload = None
|
|
510
|
+
events.append({
|
|
511
|
+
"id": row["id"],
|
|
512
|
+
"task_id": row["task_id"],
|
|
513
|
+
"run_id": row["run_id"],
|
|
514
|
+
"kind": row["kind"],
|
|
515
|
+
"payload": payload,
|
|
516
|
+
"created_at": row["created_at"],
|
|
517
|
+
})
|
|
518
|
+
cursor = int(row["id"])
|
|
519
|
+
latest = _latest_event_id(conn)
|
|
520
|
+
if not events:
|
|
521
|
+
cursor = latest if since >= latest else since
|
|
522
|
+
return {"events": events, "cursor": cursor, "latest_event_id": cursor, "read_only": False}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _config_payload(*, board=None):
|
|
526
|
+
kb = _kb()
|
|
527
|
+
try:
|
|
528
|
+
with _conn(board=board) as conn:
|
|
529
|
+
try:
|
|
530
|
+
assignees = list(kb.known_assignees(conn))
|
|
531
|
+
except Exception:
|
|
532
|
+
assignees = []
|
|
533
|
+
except Exception:
|
|
534
|
+
assignees = []
|
|
535
|
+
try:
|
|
536
|
+
from hermes_cli.config import load_config
|
|
537
|
+
|
|
538
|
+
cfg = load_config() or {}
|
|
539
|
+
except Exception:
|
|
540
|
+
cfg = {}
|
|
541
|
+
k_cfg = ((cfg.get("dashboard") or {}).get("kanban") or {})
|
|
542
|
+
return {
|
|
543
|
+
"columns": BOARD_COLUMNS,
|
|
544
|
+
"assignees": assignees,
|
|
545
|
+
"default_tenant": k_cfg.get("default_tenant") or "",
|
|
546
|
+
"lane_by_profile": bool(k_cfg.get("lane_by_profile", True)),
|
|
547
|
+
"include_archived_by_default": bool(k_cfg.get("include_archived_by_default", False)),
|
|
548
|
+
"render_markdown": bool(k_cfg.get("render_markdown", True)),
|
|
549
|
+
"read_only": False,
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _stats_payload(*, board=None):
|
|
554
|
+
kb = _kb()
|
|
555
|
+
with _conn(board=board) as conn:
|
|
556
|
+
if hasattr(kb, "board_stats"):
|
|
557
|
+
return kb.board_stats(conn)
|
|
558
|
+
rows = conn.execute(
|
|
559
|
+
"SELECT status, assignee, COUNT(*) AS n FROM tasks WHERE status != 'archived' GROUP BY status, assignee"
|
|
560
|
+
).fetchall()
|
|
561
|
+
by_status = {}
|
|
562
|
+
by_assignee = {}
|
|
563
|
+
for row in rows:
|
|
564
|
+
n = int(row["n"] or 0)
|
|
565
|
+
by_status[row["status"]] = by_status.get(row["status"], 0) + n
|
|
566
|
+
assignee = row["assignee"] or "unassigned"
|
|
567
|
+
by_assignee[assignee] = by_assignee.get(assignee, 0) + n
|
|
568
|
+
return {"by_status": by_status, "by_assignee": by_assignee}
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _assignees_payload(*, board=None):
|
|
572
|
+
kb = _kb()
|
|
573
|
+
with _conn(board=board) as conn:
|
|
574
|
+
try:
|
|
575
|
+
assignees = list(kb.known_assignees(conn))
|
|
576
|
+
except Exception:
|
|
577
|
+
rows = conn.execute(
|
|
578
|
+
"SELECT DISTINCT assignee FROM tasks WHERE assignee IS NOT NULL AND assignee != '' ORDER BY assignee"
|
|
579
|
+
).fetchall()
|
|
580
|
+
assignees = [row["assignee"] for row in rows]
|
|
581
|
+
return {"assignees": assignees}
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _task_log_payload(parsed, task_id: str):
|
|
585
|
+
board = _resolve_board(parsed)
|
|
586
|
+
kb = _kb()
|
|
587
|
+
tail = _int_query(parsed, "tail", None, minimum=1, maximum=2_000_000)
|
|
588
|
+
with _conn(board=board) as conn:
|
|
589
|
+
if not kb.get_task(conn, task_id):
|
|
590
|
+
return None
|
|
591
|
+
if not hasattr(kb, "read_worker_log"):
|
|
592
|
+
return {"task_id": task_id, "path": "", "exists": False, "size_bytes": 0, "content": "", "truncated": False}
|
|
593
|
+
content = kb.read_worker_log(task_id, tail_bytes=tail)
|
|
594
|
+
log_path = kb.worker_log_path(task_id) if hasattr(kb, "worker_log_path") else None
|
|
595
|
+
try:
|
|
596
|
+
size = log_path.stat().st_size if log_path and log_path.exists() else 0
|
|
597
|
+
except OSError:
|
|
598
|
+
size = 0
|
|
599
|
+
return {
|
|
600
|
+
"task_id": task_id,
|
|
601
|
+
"path": str(log_path or ""),
|
|
602
|
+
"exists": content is not None,
|
|
603
|
+
"size_bytes": size,
|
|
604
|
+
"content": content or "",
|
|
605
|
+
"truncated": bool(tail and size > tail),
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _bulk_tasks_payload(body: dict, *, board=None):
|
|
610
|
+
ids = [str(i).strip() for i in (body.get("ids") or []) if str(i).strip()]
|
|
611
|
+
if not ids:
|
|
612
|
+
raise ValueError("ids is required")
|
|
613
|
+
results = []
|
|
614
|
+
kb = _kb()
|
|
615
|
+
with _conn(board=board) as conn:
|
|
616
|
+
for task_id in ids:
|
|
617
|
+
entry = {"id": task_id, "ok": True}
|
|
618
|
+
try:
|
|
619
|
+
if not kb.get_task(conn, task_id):
|
|
620
|
+
entry.update(ok=False, error="not found")
|
|
621
|
+
results.append(entry)
|
|
622
|
+
continue
|
|
623
|
+
if body.get("archive"):
|
|
624
|
+
if not kb.archive_task(conn, task_id):
|
|
625
|
+
entry.update(ok=False, error="archive refused")
|
|
626
|
+
elif body.get("status") is not None:
|
|
627
|
+
_patch_task(conn, task_id, {"status": body.get("status")})
|
|
628
|
+
if body.get("assignee") is not None:
|
|
629
|
+
if not kb.assign_task(conn, task_id, body.get("assignee") or None):
|
|
630
|
+
entry.update(ok=False, error="assign refused")
|
|
631
|
+
if body.get("priority") is not None:
|
|
632
|
+
try:
|
|
633
|
+
priority = int(body.get("priority"))
|
|
634
|
+
except (TypeError, ValueError):
|
|
635
|
+
entry.update(ok=False, error="priority must be an integer")
|
|
636
|
+
else:
|
|
637
|
+
conn.execute("UPDATE tasks SET priority = ? WHERE id = ?", (priority, task_id))
|
|
638
|
+
if hasattr(kb, "_append_event"):
|
|
639
|
+
kb._append_event(conn, task_id, "reprioritized", {"priority": priority, "source": "webui"})
|
|
640
|
+
except Exception as exc:
|
|
641
|
+
entry.update(ok=False, error=str(exc))
|
|
642
|
+
results.append(entry)
|
|
643
|
+
return {"results": results, "read_only": False}
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _dispatch_payload(parsed):
|
|
647
|
+
board = _resolve_board(parsed)
|
|
648
|
+
kb = _kb()
|
|
649
|
+
dry_run = _bool_query(parsed, "dry_run", False)
|
|
650
|
+
max_spawn = _int_query(parsed, "max", 8, minimum=1, maximum=100)
|
|
651
|
+
if not hasattr(kb, "dispatch_once"):
|
|
652
|
+
raise ValueError("dispatcher is unavailable")
|
|
653
|
+
with _conn(board=board) as conn:
|
|
654
|
+
result = kb.dispatch_once(conn, dry_run=dry_run, max_spawn=max_spawn)
|
|
655
|
+
if isinstance(result, dict):
|
|
656
|
+
return result
|
|
657
|
+
try:
|
|
658
|
+
return asdict(result)
|
|
659
|
+
except TypeError:
|
|
660
|
+
return {"result": str(result)}
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def _task_action_payload(task_id: str, body: dict, action: str, *, board=None):
|
|
664
|
+
kb = _kb()
|
|
665
|
+
task_id = str(task_id or "").strip()
|
|
666
|
+
if not task_id:
|
|
667
|
+
raise ValueError("task_id is required")
|
|
668
|
+
with _conn(board=board) as conn:
|
|
669
|
+
if not kb.get_task(conn, task_id):
|
|
670
|
+
raise LookupError("task not found")
|
|
671
|
+
if action == "block":
|
|
672
|
+
ok = kb.block_task(conn, task_id, reason=body.get("reason") or body.get("block_reason"))
|
|
673
|
+
elif action == "unblock":
|
|
674
|
+
if hasattr(kb, "unblock_task"):
|
|
675
|
+
ok = kb.unblock_task(conn, task_id)
|
|
676
|
+
else:
|
|
677
|
+
_patch_task(conn, task_id, {"status": "ready"})
|
|
678
|
+
ok = True
|
|
679
|
+
else:
|
|
680
|
+
raise ValueError(f"invalid action: {action}")
|
|
681
|
+
if not ok:
|
|
682
|
+
raise RuntimeError(f"{action} refused")
|
|
683
|
+
return {"task": _task_dict(kb.get_task(conn, task_id)), "read_only": False}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
# ---------------------------------------------------------------------------
|
|
687
|
+
# Multi-board management
|
|
688
|
+
# ---------------------------------------------------------------------------
|
|
689
|
+
# These endpoints operate on the on-disk board collection itself rather than
|
|
690
|
+
# on the tasks of a single board. They mirror the agent dashboard plugin's
|
|
691
|
+
# /boards surface (plugins/kanban/dashboard/plugin_api.py) so that the
|
|
692
|
+
# CLI / gateway / dashboard / WebUI all share the same active-board pointer.
|
|
693
|
+
|
|
694
|
+
def _board_meta_dict(meta):
|
|
695
|
+
"""Coerce the library's board metadata dict into a JSON-serialisable
|
|
696
|
+
form. ``list_boards`` returns dicts with Path values for ``directory``;
|
|
697
|
+
json.dumps would refuse those without help."""
|
|
698
|
+
if not isinstance(meta, dict):
|
|
699
|
+
return meta
|
|
700
|
+
out = dict(meta)
|
|
701
|
+
for key in ("directory", "db_path", "path"):
|
|
702
|
+
if key in out and out[key] is not None:
|
|
703
|
+
out[key] = str(out[key])
|
|
704
|
+
return out
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def _board_counts_for_slug(slug):
|
|
708
|
+
"""Per-status task counts for a board, used to populate the board
|
|
709
|
+
switcher with a live "12 tasks" badge. Mirrors the agent dashboard's
|
|
710
|
+
``_board_counts`` helper. Returns an empty dict for boards whose
|
|
711
|
+
sqlite file has not been materialized yet (freshly-created boards
|
|
712
|
+
with no tasks)."""
|
|
713
|
+
kb = _kb()
|
|
714
|
+
if not kb.board_exists(slug):
|
|
715
|
+
return {}
|
|
716
|
+
try:
|
|
717
|
+
conn = kb.connect(board=slug)
|
|
718
|
+
except Exception:
|
|
719
|
+
return {}
|
|
720
|
+
try:
|
|
721
|
+
rows = conn.execute(
|
|
722
|
+
"SELECT status, COUNT(*) AS n FROM tasks "
|
|
723
|
+
"WHERE status != 'archived' GROUP BY status"
|
|
724
|
+
).fetchall()
|
|
725
|
+
return {row["status"]: int(row["n"] or 0) for row in rows}
|
|
726
|
+
except Exception:
|
|
727
|
+
return {}
|
|
728
|
+
finally:
|
|
729
|
+
try:
|
|
730
|
+
conn.close()
|
|
731
|
+
except Exception:
|
|
732
|
+
pass
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def _list_boards_payload(parsed):
|
|
736
|
+
"""GET /api/kanban/boards — return all boards on disk + active slug.
|
|
737
|
+
|
|
738
|
+
Each entry includes per-status counts and an ``is_current`` flag so the
|
|
739
|
+
UI can render the switcher in a single round-trip.
|
|
740
|
+
"""
|
|
741
|
+
kb = _kb()
|
|
742
|
+
include_archived = _bool_query(parsed, "include_archived", False)
|
|
743
|
+
boards = kb.list_boards(include_archived=include_archived)
|
|
744
|
+
try:
|
|
745
|
+
current = kb.get_current_board()
|
|
746
|
+
except Exception:
|
|
747
|
+
current = "default"
|
|
748
|
+
visible_slugs = {(_board_meta_dict(meta).get("slug")) for meta in boards}
|
|
749
|
+
default_slug = getattr(kb, "DEFAULT_BOARD", "default")
|
|
750
|
+
if current not in visible_slugs:
|
|
751
|
+
# The on-disk active-board pointer can outlive an archived/deleted board
|
|
752
|
+
# when another CLI/WebUI process removes it. Surface a valid current
|
|
753
|
+
# board instead of letting the frontend pin every subsequent request to
|
|
754
|
+
# a ghost slug and fail with an opaque 404.
|
|
755
|
+
try:
|
|
756
|
+
kb.clear_current_board()
|
|
757
|
+
except Exception:
|
|
758
|
+
pass
|
|
759
|
+
current = default_slug
|
|
760
|
+
out = []
|
|
761
|
+
for raw_meta in boards:
|
|
762
|
+
meta = _board_meta_dict(raw_meta)
|
|
763
|
+
slug = meta.get("slug")
|
|
764
|
+
if slug is None:
|
|
765
|
+
continue
|
|
766
|
+
meta["is_current"] = (slug == current)
|
|
767
|
+
meta["counts"] = _board_counts_for_slug(slug)
|
|
768
|
+
meta["total"] = sum(meta["counts"].values()) if meta["counts"] else 0
|
|
769
|
+
out.append(meta)
|
|
770
|
+
return {"boards": out, "current": current, "read_only": False}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def _create_board_payload(body):
|
|
774
|
+
"""POST /api/kanban/boards — create a new board.
|
|
775
|
+
|
|
776
|
+
Body fields: ``slug`` (required), ``name``, ``description``, ``icon``,
|
|
777
|
+
``color``, ``switch`` (bool — set as active after creation, default false).
|
|
778
|
+
Idempotent on slug — repeating returns the existing board metadata.
|
|
779
|
+
"""
|
|
780
|
+
kb = _kb()
|
|
781
|
+
if not isinstance(body, dict):
|
|
782
|
+
raise ValueError("body must be a JSON object")
|
|
783
|
+
slug = str(body.get("slug") or "").strip()
|
|
784
|
+
if not slug:
|
|
785
|
+
raise ValueError("slug is required")
|
|
786
|
+
try:
|
|
787
|
+
meta = kb.create_board(
|
|
788
|
+
slug,
|
|
789
|
+
name=body.get("name") or None,
|
|
790
|
+
description=body.get("description") or None,
|
|
791
|
+
icon=body.get("icon") or None,
|
|
792
|
+
color=body.get("color") or None,
|
|
793
|
+
)
|
|
794
|
+
except (ValueError, AttributeError) as exc:
|
|
795
|
+
raise ValueError(str(exc)) from exc
|
|
796
|
+
if body.get("switch"):
|
|
797
|
+
try:
|
|
798
|
+
kb.set_current_board(meta["slug"])
|
|
799
|
+
except (ValueError, AttributeError) as exc:
|
|
800
|
+
raise ValueError(str(exc)) from exc
|
|
801
|
+
try:
|
|
802
|
+
current = kb.get_current_board()
|
|
803
|
+
except Exception:
|
|
804
|
+
current = "default"
|
|
805
|
+
return {"board": _board_meta_dict(meta), "current": current, "read_only": False}
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _update_board_payload(slug, body):
|
|
809
|
+
"""PATCH /api/kanban/boards/<slug> — update a board's display metadata.
|
|
810
|
+
|
|
811
|
+
The slug itself is immutable (changing it would mean moving the on-disk
|
|
812
|
+
directory and re-pointing every saved active-board cookie). Only
|
|
813
|
+
``name``, ``description``, ``icon``, ``color``, and ``archived`` are
|
|
814
|
+
mutable here; the slug travels in the URL path.
|
|
815
|
+
"""
|
|
816
|
+
kb = _kb()
|
|
817
|
+
if not isinstance(body, dict):
|
|
818
|
+
raise ValueError("body must be a JSON object")
|
|
819
|
+
try:
|
|
820
|
+
normed = kb._normalize_board_slug(slug)
|
|
821
|
+
except (ValueError, AttributeError) as exc:
|
|
822
|
+
raise ValueError(f"invalid board slug: {slug!r}") from exc
|
|
823
|
+
if not normed or not kb.board_exists(normed):
|
|
824
|
+
raise LookupError(f"board {slug!r} does not exist")
|
|
825
|
+
archived = body.get("archived")
|
|
826
|
+
if isinstance(archived, str):
|
|
827
|
+
archived = archived.strip().lower() in {"1", "true", "yes", "on"}
|
|
828
|
+
meta = kb.write_board_metadata(
|
|
829
|
+
normed,
|
|
830
|
+
name=body.get("name"),
|
|
831
|
+
description=body.get("description"),
|
|
832
|
+
icon=body.get("icon"),
|
|
833
|
+
color=body.get("color"),
|
|
834
|
+
archived=archived if isinstance(archived, bool) else None,
|
|
835
|
+
)
|
|
836
|
+
return {"board": _board_meta_dict(meta), "read_only": False}
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _delete_board_payload(slug, parsed):
|
|
840
|
+
"""DELETE /api/kanban/boards/<slug> — archive (default) or hard-delete.
|
|
841
|
+
|
|
842
|
+
``?delete=1`` is required to actually remove on-disk artefacts; without
|
|
843
|
+
it the board is just marked archived in its metadata and remains
|
|
844
|
+
enumerable via ``?include_archived=1`` on /boards.
|
|
845
|
+
"""
|
|
846
|
+
kb = _kb()
|
|
847
|
+
hard_delete = _bool_query(parsed, "delete", False)
|
|
848
|
+
try:
|
|
849
|
+
normed = kb._normalize_board_slug(slug)
|
|
850
|
+
except (ValueError, AttributeError) as exc:
|
|
851
|
+
raise ValueError(f"invalid board slug: {slug!r}") from exc
|
|
852
|
+
if not normed or not kb.board_exists(normed):
|
|
853
|
+
raise LookupError(f"board {slug!r} does not exist")
|
|
854
|
+
# Refuse to delete the default board — that would leave the system
|
|
855
|
+
# without a fallback active board on next CLI / dashboard call.
|
|
856
|
+
try:
|
|
857
|
+
default_slug = getattr(kb, "DEFAULT_BOARD", "default")
|
|
858
|
+
except Exception:
|
|
859
|
+
default_slug = "default"
|
|
860
|
+
if normed == default_slug:
|
|
861
|
+
raise ValueError("cannot remove the default board")
|
|
862
|
+
res = kb.remove_board(normed, archive=not hard_delete)
|
|
863
|
+
try:
|
|
864
|
+
current = kb.get_current_board()
|
|
865
|
+
except Exception:
|
|
866
|
+
current = "default"
|
|
867
|
+
# If we just removed the active board, the library auto-falls-back to
|
|
868
|
+
# default on the next get_current_board() — surface that explicitly so
|
|
869
|
+
# the UI can re-fetch /board on the new active slug.
|
|
870
|
+
return {
|
|
871
|
+
"result": _board_meta_dict(res) if isinstance(res, dict) else res,
|
|
872
|
+
"current": current,
|
|
873
|
+
"read_only": False,
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def _switch_board_payload(slug):
|
|
878
|
+
"""POST /api/kanban/boards/<slug>/switch — set this board as active.
|
|
879
|
+
|
|
880
|
+
The active-board pointer is stored on disk under ``<root>/kanban/current``
|
|
881
|
+
and is shared by the CLI, gateway, dashboard, and WebUI — switching
|
|
882
|
+
here switches everywhere. The UI also keeps a localStorage hint so
|
|
883
|
+
that opening a fresh tab doesn't always have to round-trip to discover
|
|
884
|
+
the active slug, but the on-disk pointer is the source of truth.
|
|
885
|
+
"""
|
|
886
|
+
kb = _kb()
|
|
887
|
+
try:
|
|
888
|
+
normed = kb._normalize_board_slug(slug)
|
|
889
|
+
except (ValueError, AttributeError) as exc:
|
|
890
|
+
raise ValueError(f"invalid board slug: {slug!r}") from exc
|
|
891
|
+
if not normed or not kb.board_exists(normed):
|
|
892
|
+
raise LookupError(f"board {slug!r} does not exist")
|
|
893
|
+
kb.set_current_board(normed)
|
|
894
|
+
return {"current": normed, "read_only": False}
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
# ---------------------------------------------------------------------------
|
|
898
|
+
# SSE event stream
|
|
899
|
+
# ---------------------------------------------------------------------------
|
|
900
|
+
# Server-Sent Events let the UI react to task transitions in real time
|
|
901
|
+
# without the 30s HTTP polling tax. The agent dashboard uses WebSockets
|
|
902
|
+
# for the same purpose; we use SSE because the WebUI's existing transport
|
|
903
|
+
# is a synchronous BaseHTTPServer and SSE is the right tool for
|
|
904
|
+
# unidirectional server-pushed event streams. The wire-level UX is
|
|
905
|
+
# identical from the client's perspective: events arrive within ~300ms
|
|
906
|
+
# of being committed to task_events.
|
|
907
|
+
|
|
908
|
+
# Polling interval matches the agent dashboard's _EVENT_POLL_SECONDS so
|
|
909
|
+
# write-to-receive latency is identical between the two surfaces.
|
|
910
|
+
_KANBAN_SSE_POLL_SECONDS = 0.3
|
|
911
|
+
# Heartbeat keeps proxies/CDNs from reaping the connection on idle boards.
|
|
912
|
+
# Identical to the approval/clarify SSE heartbeat.
|
|
913
|
+
_KANBAN_SSE_HEARTBEAT_SECONDS = 15.0
|
|
914
|
+
# Hard cap on a single SSE batch so a board with thousands of historical
|
|
915
|
+
# events doesn't ship them all in one frame. Same as the dashboard.
|
|
916
|
+
_KANBAN_SSE_BATCH_LIMIT = 200
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def _kanban_sse_fetch_new(board, cursor):
|
|
920
|
+
"""Read events with id > cursor from the given board's task_events
|
|
921
|
+
table. Returns ``(new_cursor, events_list)``. Best-effort — returns
|
|
922
|
+
the input cursor and an empty list on any DB error so the SSE loop
|
|
923
|
+
self-heals on transient sqlite contention rather than dropping the
|
|
924
|
+
client."""
|
|
925
|
+
kb = _kb()
|
|
926
|
+
# Guard against a board that's been archived/removed mid-stream:
|
|
927
|
+
# kb.connect(board=<slug>) auto-materialises the directory + DB on
|
|
928
|
+
# first call, which would silently un-archive a board that was just
|
|
929
|
+
# removed. Skip the fetch when the board no longer exists.
|
|
930
|
+
if board is not None:
|
|
931
|
+
try:
|
|
932
|
+
default_slug = getattr(kb, "DEFAULT_BOARD", "default")
|
|
933
|
+
except Exception:
|
|
934
|
+
default_slug = "default"
|
|
935
|
+
if board != default_slug and not kb.board_exists(board):
|
|
936
|
+
return cursor, []
|
|
937
|
+
try:
|
|
938
|
+
conn = kb.connect(board=board)
|
|
939
|
+
except Exception:
|
|
940
|
+
return cursor, []
|
|
941
|
+
try:
|
|
942
|
+
rows = conn.execute(
|
|
943
|
+
"SELECT id, task_id, run_id, kind, payload, created_at "
|
|
944
|
+
"FROM task_events WHERE id > ? ORDER BY id ASC LIMIT ?",
|
|
945
|
+
(int(cursor), _KANBAN_SSE_BATCH_LIMIT),
|
|
946
|
+
).fetchall()
|
|
947
|
+
except Exception:
|
|
948
|
+
return cursor, []
|
|
949
|
+
finally:
|
|
950
|
+
try:
|
|
951
|
+
conn.close()
|
|
952
|
+
except Exception:
|
|
953
|
+
pass
|
|
954
|
+
out = []
|
|
955
|
+
new_cursor = cursor
|
|
956
|
+
for r in rows:
|
|
957
|
+
payload = None
|
|
958
|
+
try:
|
|
959
|
+
raw = r["payload"]
|
|
960
|
+
if raw:
|
|
961
|
+
payload = json.loads(raw)
|
|
962
|
+
except Exception:
|
|
963
|
+
payload = None
|
|
964
|
+
out.append({
|
|
965
|
+
"id": int(r["id"]),
|
|
966
|
+
"task_id": r["task_id"],
|
|
967
|
+
"run_id": r["run_id"],
|
|
968
|
+
"kind": r["kind"],
|
|
969
|
+
"payload": payload,
|
|
970
|
+
"created_at": int(r["created_at"]) if r["created_at"] is not None else None,
|
|
971
|
+
})
|
|
972
|
+
new_cursor = int(r["id"])
|
|
973
|
+
return new_cursor, out
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _handle_events_sse_stream(handler, parsed):
|
|
977
|
+
"""GET /api/kanban/events/stream — long-lived SSE feed of task events.
|
|
978
|
+
|
|
979
|
+
Query params:
|
|
980
|
+
since=<int> Resume from this event id. Defaults to 0 (full backlog
|
|
981
|
+
on first connect — the client should pass the latest
|
|
982
|
+
id it knows about so it does not re-receive historical
|
|
983
|
+
events.) Capped to the most recent _KANBAN_SSE_BATCH_LIMIT.
|
|
984
|
+
board=<slug> Pin the stream to a specific board. Switching boards
|
|
985
|
+
requires the client to close and re-open the stream.
|
|
986
|
+
|
|
987
|
+
Header (set automatically by EventSource on reconnect):
|
|
988
|
+
Last-Event-ID Fallback resume cursor when ?since= is absent. The
|
|
989
|
+
server emits ``id: <event_id>`` on every events frame
|
|
990
|
+
so the browser can resume cleanly across drops without
|
|
991
|
+
re-receiving up to _KANBAN_SSE_BATCH_LIMIT events the
|
|
992
|
+
client already has.
|
|
993
|
+
|
|
994
|
+
Mirrors the agent dashboard's WebSocket /events contract event-for-event
|
|
995
|
+
so a client that handles one can handle the other with only the
|
|
996
|
+
transport swapped.
|
|
997
|
+
"""
|
|
998
|
+
try:
|
|
999
|
+
board = _resolve_board(parsed)
|
|
1000
|
+
except (ValueError, LookupError) as exc:
|
|
1001
|
+
return bad(handler, str(exc), status=400 if isinstance(exc, ValueError) else 404)
|
|
1002
|
+
|
|
1003
|
+
qs = parse_qs(parsed.query or "")
|
|
1004
|
+
# Resolution chain: ?since= query param → Last-Event-ID header → 0.
|
|
1005
|
+
# The Last-Event-ID header is what EventSource sends automatically on
|
|
1006
|
+
# reconnect; honouring it lets the browser resume cleanly without the
|
|
1007
|
+
# client needing to track the cursor in JS.
|
|
1008
|
+
since_raw = (qs.get("since") or [None])[0]
|
|
1009
|
+
if since_raw is None:
|
|
1010
|
+
try:
|
|
1011
|
+
since_raw = handler.headers.get("Last-Event-ID")
|
|
1012
|
+
except Exception:
|
|
1013
|
+
since_raw = None
|
|
1014
|
+
try:
|
|
1015
|
+
cursor = int(since_raw) if since_raw is not None else 0
|
|
1016
|
+
except (TypeError, ValueError):
|
|
1017
|
+
cursor = 0
|
|
1018
|
+
if cursor < 0:
|
|
1019
|
+
cursor = 0
|
|
1020
|
+
|
|
1021
|
+
handler.send_response(200)
|
|
1022
|
+
handler.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
1023
|
+
handler.send_header("Cache-Control", "no-cache")
|
|
1024
|
+
handler.send_header("X-Accel-Buffering", "no")
|
|
1025
|
+
handler.send_header("Connection", "close")
|
|
1026
|
+
handler.end_headers()
|
|
1027
|
+
|
|
1028
|
+
# Send an initial frame so the client knows the connection is open
|
|
1029
|
+
# and learns the current cursor (in case the server already had a
|
|
1030
|
+
# backlog when the client first connected).
|
|
1031
|
+
try:
|
|
1032
|
+
handler.wfile.write(
|
|
1033
|
+
f"event: hello\ndata: {json.dumps({'cursor': cursor, 'board': board})}\n\n".encode("utf-8")
|
|
1034
|
+
)
|
|
1035
|
+
handler.wfile.flush()
|
|
1036
|
+
except (BrokenPipeError, ConnectionResetError, ValueError, OSError):
|
|
1037
|
+
return True
|
|
1038
|
+
|
|
1039
|
+
last_heartbeat = time.monotonic()
|
|
1040
|
+
try:
|
|
1041
|
+
while True:
|
|
1042
|
+
cursor, events = _kanban_sse_fetch_new(board, cursor)
|
|
1043
|
+
if events:
|
|
1044
|
+
# Emit `id: <last_event_id>` on every events frame so the
|
|
1045
|
+
# browser sets Last-Event-ID on auto-reconnect, letting us
|
|
1046
|
+
# resume from there without re-streaming the backlog.
|
|
1047
|
+
payload = json.dumps({"events": events, "cursor": cursor})
|
|
1048
|
+
frame = (
|
|
1049
|
+
f"id: {cursor}\nevent: events\ndata: {payload}\n\n"
|
|
1050
|
+
).encode("utf-8")
|
|
1051
|
+
try:
|
|
1052
|
+
handler.wfile.write(frame)
|
|
1053
|
+
handler.wfile.flush()
|
|
1054
|
+
except (BrokenPipeError, ConnectionResetError, ValueError, OSError):
|
|
1055
|
+
return True
|
|
1056
|
+
last_heartbeat = time.monotonic()
|
|
1057
|
+
else:
|
|
1058
|
+
# Heartbeat keeps reverse proxies and the browser from
|
|
1059
|
+
# closing an idle stream. SSE comments (lines starting
|
|
1060
|
+
# with `:`) are ignored by EventSource.
|
|
1061
|
+
if (time.monotonic() - last_heartbeat) >= _KANBAN_SSE_HEARTBEAT_SECONDS:
|
|
1062
|
+
try:
|
|
1063
|
+
handler.wfile.write(b": keepalive\n\n")
|
|
1064
|
+
handler.wfile.flush()
|
|
1065
|
+
except (BrokenPipeError, ConnectionResetError, ValueError, OSError):
|
|
1066
|
+
return True
|
|
1067
|
+
last_heartbeat = time.monotonic()
|
|
1068
|
+
time.sleep(_KANBAN_SSE_POLL_SECONDS)
|
|
1069
|
+
except Exception:
|
|
1070
|
+
# Any other unexpected exception in the SSE loop should not bubble
|
|
1071
|
+
# up to the request handler (which would 500 a long-lived stream).
|
|
1072
|
+
return True
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def handle_kanban_get(handler, parsed) -> bool | None:
|
|
1076
|
+
"""Dispatch a Kanban GET. Three-valued return:
|
|
1077
|
+
|
|
1078
|
+
- ``False`` — no Kanban path matched; caller should emit a 404
|
|
1079
|
+
(``_kanban_unknown_endpoint``) for genuinely stale-bundle requests.
|
|
1080
|
+
- ``None`` — a path matched and the inner handler already sent a
|
|
1081
|
+
response via ``bad(...)`` / ``j(...)`` (which both return ``None``).
|
|
1082
|
+
The caller MUST NOT emit another response.
|
|
1083
|
+
- ``True`` — a path matched and the inner handler succeeded.
|
|
1084
|
+
|
|
1085
|
+
Treat any falsy-but-not-False return (``0``, ``''``, etc.) as a bug and
|
|
1086
|
+
audit the new return path; the caller uses ``is False`` identity check
|
|
1087
|
+
to distinguish unmatched paths from already-responded paths (#1843).
|
|
1088
|
+
"""
|
|
1089
|
+
path = parsed.path
|
|
1090
|
+
try:
|
|
1091
|
+
# Multi-board management endpoints — these do NOT take a board arg
|
|
1092
|
+
# because they operate on the on-disk board collection itself, not
|
|
1093
|
+
# on a single board's tasks.
|
|
1094
|
+
if path == "/api/kanban/boards":
|
|
1095
|
+
return j(handler, _list_boards_payload(parsed)) or True
|
|
1096
|
+
if path == "/api/kanban/board":
|
|
1097
|
+
return j(handler, _board_payload(parsed)) or True
|
|
1098
|
+
if path == "/api/kanban/config":
|
|
1099
|
+
return j(handler, _config_payload(board=_resolve_board(parsed))) or True
|
|
1100
|
+
if path == "/api/kanban/stats":
|
|
1101
|
+
return j(handler, _stats_payload(board=_resolve_board(parsed))) or True
|
|
1102
|
+
if path == "/api/kanban/assignees":
|
|
1103
|
+
return j(handler, _assignees_payload(board=_resolve_board(parsed))) or True
|
|
1104
|
+
if path == "/api/kanban/events":
|
|
1105
|
+
return j(handler, _events_payload(parsed)) or True
|
|
1106
|
+
if path == "/api/kanban/events/stream":
|
|
1107
|
+
return _handle_events_sse_stream(handler, parsed)
|
|
1108
|
+
if path.startswith(_TASK_PREFIX) and path.endswith("/log"):
|
|
1109
|
+
task_id = unquote(path[len(_TASK_PREFIX):-len("/log")]).strip("/")
|
|
1110
|
+
if not task_id or "/" in task_id:
|
|
1111
|
+
return False
|
|
1112
|
+
payload = _task_log_payload(parsed, task_id)
|
|
1113
|
+
if payload is None:
|
|
1114
|
+
return bad(handler, "task not found", status=404)
|
|
1115
|
+
return j(handler, payload) or True
|
|
1116
|
+
if path.startswith(_TASK_PREFIX):
|
|
1117
|
+
task_id = unquote(path[len(_TASK_PREFIX):]).strip("/")
|
|
1118
|
+
if not task_id or "/" in task_id:
|
|
1119
|
+
return False
|
|
1120
|
+
payload = _task_detail_payload(task_id, board=_resolve_board(parsed))
|
|
1121
|
+
if payload is None:
|
|
1122
|
+
return bad(handler, "task not found", status=404)
|
|
1123
|
+
return j(handler, payload) or True
|
|
1124
|
+
return False
|
|
1125
|
+
except ImportError as exc:
|
|
1126
|
+
# hermes_cli not installed (webui-only deploy). Return a clean 503
|
|
1127
|
+
# "kanban unavailable" rather than a 500 so the frontend's existing
|
|
1128
|
+
# try/catch surfaces a useful toast.
|
|
1129
|
+
return bad(handler, f"kanban unavailable: {exc}", status=503)
|
|
1130
|
+
except LookupError as exc:
|
|
1131
|
+
return bad(handler, str(exc), status=404)
|
|
1132
|
+
except ValueError as exc:
|
|
1133
|
+
return bad(handler, str(exc))
|
|
1134
|
+
except RuntimeError as exc:
|
|
1135
|
+
return bad(handler, str(exc), status=409)
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def handle_kanban_post(handler, parsed, body) -> bool | None:
|
|
1139
|
+
"""Dispatch a Kanban POST. See ``handle_kanban_get`` for the
|
|
1140
|
+
three-valued ``True | None | False`` contract (#1843)."""
|
|
1141
|
+
path = parsed.path
|
|
1142
|
+
try:
|
|
1143
|
+
# Multi-board management endpoints — `_create_board_payload` and
|
|
1144
|
+
# `_switch_board_payload` operate on the on-disk board collection,
|
|
1145
|
+
# not on a single board's tasks.
|
|
1146
|
+
if path == "/api/kanban/boards":
|
|
1147
|
+
return j(handler, _create_board_payload(body)) or True
|
|
1148
|
+
# POST /api/kanban/boards/<slug>/switch — set active board
|
|
1149
|
+
_BOARDS_PREFIX = "/api/kanban/boards/"
|
|
1150
|
+
if path.startswith(_BOARDS_PREFIX) and path.endswith("/switch"):
|
|
1151
|
+
slug = unquote(path[len(_BOARDS_PREFIX):-len("/switch")]).strip("/")
|
|
1152
|
+
if not slug or "/" in slug:
|
|
1153
|
+
return False
|
|
1154
|
+
return j(handler, _switch_board_payload(slug)) or True
|
|
1155
|
+
# All board-scoped writes accept a ?board=<slug> query param OR a
|
|
1156
|
+
# `board` field in the JSON body. Query takes precedence.
|
|
1157
|
+
board_q = _resolve_board(parsed)
|
|
1158
|
+
board_b = _resolve_board_from_body(body)
|
|
1159
|
+
board = board_q if board_q is not None else board_b
|
|
1160
|
+
if path == "/api/kanban/dispatch":
|
|
1161
|
+
return j(handler, _dispatch_payload(parsed)) or True
|
|
1162
|
+
if path == "/api/kanban/tasks/bulk":
|
|
1163
|
+
return j(handler, _bulk_tasks_payload(body, board=board)) or True
|
|
1164
|
+
if path == "/api/kanban/tasks":
|
|
1165
|
+
return j(handler, _create_task_payload(body, board=board)) or True
|
|
1166
|
+
if path == "/api/kanban/links":
|
|
1167
|
+
return j(handler, _link_tasks_payload(body, board=board)) or True
|
|
1168
|
+
if path == "/api/kanban/links/delete":
|
|
1169
|
+
return j(handler, _link_tasks_payload(body, unlink=True, board=board)) or True
|
|
1170
|
+
if path.startswith(_TASK_PREFIX) and path.endswith("/comments"):
|
|
1171
|
+
task_id = path[len(_TASK_PREFIX):-len("/comments")].strip("/")
|
|
1172
|
+
return j(handler, _comment_payload(task_id, body, board=board)) or True
|
|
1173
|
+
for suffix, action in (("/block", "block"), ("/unblock", "unblock")):
|
|
1174
|
+
if path.startswith(_TASK_PREFIX) and path.endswith(suffix):
|
|
1175
|
+
task_id = path[len(_TASK_PREFIX):-len(suffix)].strip("/")
|
|
1176
|
+
return j(handler, _task_action_payload(task_id, body, action, board=board)) or True
|
|
1177
|
+
if path.startswith(_TASK_PREFIX) and path.endswith("/patch"):
|
|
1178
|
+
task_id = path[len(_TASK_PREFIX):-len("/patch")].strip("/")
|
|
1179
|
+
return j(handler, _patch_task_payload(task_id, body, board=board)) or True
|
|
1180
|
+
except ImportError as exc:
|
|
1181
|
+
return bad(handler, f"kanban unavailable: {exc}", status=503)
|
|
1182
|
+
except LookupError as exc:
|
|
1183
|
+
return bad(handler, str(exc), status=404)
|
|
1184
|
+
except ValueError as exc:
|
|
1185
|
+
return bad(handler, str(exc))
|
|
1186
|
+
except RuntimeError as exc:
|
|
1187
|
+
return bad(handler, str(exc), status=409)
|
|
1188
|
+
return False
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def handle_kanban_patch(handler, parsed, body) -> bool | None:
|
|
1192
|
+
"""Dispatch a Kanban PATCH. See ``handle_kanban_get`` for the
|
|
1193
|
+
three-valued ``True | None | False`` contract (#1843)."""
|
|
1194
|
+
path = parsed.path
|
|
1195
|
+
try:
|
|
1196
|
+
# /boards/<slug> routes operate on the on-disk board collection
|
|
1197
|
+
# itself — the slug travels in the URL path, not via ?board=. Match
|
|
1198
|
+
# them BEFORE resolving the board param so a stray ?board=ghost in
|
|
1199
|
+
# the query string doesn't 404 the legitimate `experiments` rename.
|
|
1200
|
+
# (Mirrors handle_kanban_post's structure — fixes asymmetry caught
|
|
1201
|
+
# by Opus advisor.)
|
|
1202
|
+
_BOARDS_PREFIX = "/api/kanban/boards/"
|
|
1203
|
+
if path.startswith(_BOARDS_PREFIX):
|
|
1204
|
+
slug = unquote(path[len(_BOARDS_PREFIX):]).strip("/")
|
|
1205
|
+
if not slug or "/" in slug:
|
|
1206
|
+
return False
|
|
1207
|
+
return j(handler, _update_board_payload(slug, body)) or True
|
|
1208
|
+
# Task-scoped writes accept ?board=<slug> (or body.board) to pin the
|
|
1209
|
+
# write to a specific board. Query takes precedence over body.
|
|
1210
|
+
board_q = _resolve_board(parsed)
|
|
1211
|
+
board_b = _resolve_board_from_body(body)
|
|
1212
|
+
board = board_q if board_q is not None else board_b
|
|
1213
|
+
if path.startswith(_TASK_PREFIX):
|
|
1214
|
+
task_id = unquote(path[len(_TASK_PREFIX):]).strip("/")
|
|
1215
|
+
if not task_id or "/" in task_id:
|
|
1216
|
+
return False
|
|
1217
|
+
return j(handler, _patch_task_payload(task_id, body, board=board)) or True
|
|
1218
|
+
except ImportError as exc:
|
|
1219
|
+
return bad(handler, f"kanban unavailable: {exc}", status=503)
|
|
1220
|
+
except LookupError as exc:
|
|
1221
|
+
return bad(handler, str(exc), status=404)
|
|
1222
|
+
except ValueError as exc:
|
|
1223
|
+
return bad(handler, str(exc))
|
|
1224
|
+
except RuntimeError as exc:
|
|
1225
|
+
return bad(handler, str(exc), status=409)
|
|
1226
|
+
return False
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def handle_kanban_delete(handler, parsed, body) -> bool | None:
|
|
1230
|
+
"""Dispatch a Kanban DELETE. See ``handle_kanban_get`` for the
|
|
1231
|
+
three-valued ``True | None | False`` contract (#1843)."""
|
|
1232
|
+
path = parsed.path
|
|
1233
|
+
try:
|
|
1234
|
+
# Same routing reorder as PATCH: /boards/<slug> path-routed first,
|
|
1235
|
+
# so a stray ?board=ghost can't 404 a legitimate board archive.
|
|
1236
|
+
_BOARDS_PREFIX = "/api/kanban/boards/"
|
|
1237
|
+
if path.startswith(_BOARDS_PREFIX):
|
|
1238
|
+
slug = unquote(path[len(_BOARDS_PREFIX):]).strip("/")
|
|
1239
|
+
if not slug or "/" in slug:
|
|
1240
|
+
return False
|
|
1241
|
+
return j(handler, _delete_board_payload(slug, parsed)) or True
|
|
1242
|
+
board_q = _resolve_board(parsed)
|
|
1243
|
+
board_b = _resolve_board_from_body(body)
|
|
1244
|
+
board = board_q if board_q is not None else board_b
|
|
1245
|
+
if path == "/api/kanban/links":
|
|
1246
|
+
return j(handler, _link_tasks_payload(body, unlink=True, board=board)) or True
|
|
1247
|
+
except ImportError as exc:
|
|
1248
|
+
return bad(handler, f"kanban unavailable: {exc}", status=503)
|
|
1249
|
+
except LookupError as exc:
|
|
1250
|
+
return bad(handler, str(exc), status=404)
|
|
1251
|
+
except ValueError as exc:
|
|
1252
|
+
return bad(handler, str(exc))
|
|
1253
|
+
except RuntimeError as exc:
|
|
1254
|
+
return bad(handler, str(exc), status=409)
|
|
1255
|
+
return False
|