@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.
Files changed (233) hide show
  1. package/README.md +213 -0
  2. package/bin/hermes-webui.mjs +588 -0
  3. package/package.json +25 -0
  4. package/scripts/sync-vendor.mjs +74 -0
  5. package/templates/launchd/com.bitseek.hermes-webui.plist +21 -0
  6. package/templates/systemd/hermes-webui.service +13 -0
  7. package/templates/windows/hermes-webui-task.ps1 +3 -0
  8. package/vendor/agent-frontend-shell/.bitseek-source.json +6 -0
  9. package/vendor/agent-frontend-shell/.dockerignore +7 -0
  10. package/vendor/agent-frontend-shell/.env.docker.example +89 -0
  11. package/vendor/agent-frontend-shell/.env.example +34 -0
  12. package/vendor/agent-frontend-shell/.github/FUNDING.yml +3 -0
  13. package/vendor/agent-frontend-shell/.github/workflows/browser-smoke.yml +42 -0
  14. package/vendor/agent-frontend-shell/.github/workflows/docker-smoke.yml +233 -0
  15. package/vendor/agent-frontend-shell/.github/workflows/native-windows-startup.yml +132 -0
  16. package/vendor/agent-frontend-shell/.github/workflows/release.yml +57 -0
  17. package/vendor/agent-frontend-shell/.github/workflows/tests.yml +88 -0
  18. package/vendor/agent-frontend-shell/.vscode/launch.json +59 -0
  19. package/vendor/agent-frontend-shell/.vscode/settings.json +13 -0
  20. package/vendor/agent-frontend-shell/AGENTS.md +80 -0
  21. package/vendor/agent-frontend-shell/ARCHITECTURE.md +1658 -0
  22. package/vendor/agent-frontend-shell/BUGS.md +52 -0
  23. package/vendor/agent-frontend-shell/CHANGELOG.md +7295 -0
  24. package/vendor/agent-frontend-shell/CONTRIBUTING.md +205 -0
  25. package/vendor/agent-frontend-shell/CONTRIBUTORS.md +107 -0
  26. package/vendor/agent-frontend-shell/DESIGN.md +173 -0
  27. package/vendor/agent-frontend-shell/Dockerfile +91 -0
  28. package/vendor/agent-frontend-shell/LICENSE +21 -0
  29. package/vendor/agent-frontend-shell/README-CUSTOM.md +76 -0
  30. package/vendor/agent-frontend-shell/README.md +705 -0
  31. package/vendor/agent-frontend-shell/ROADMAP.md +351 -0
  32. package/vendor/agent-frontend-shell/SPRINTS.md +147 -0
  33. package/vendor/agent-frontend-shell/TESTING.md +1932 -0
  34. package/vendor/agent-frontend-shell/THEMES.md +170 -0
  35. package/vendor/agent-frontend-shell/api/__init__.py +1 -0
  36. package/vendor/agent-frontend-shell/api/agent_health.py +392 -0
  37. package/vendor/agent-frontend-shell/api/agent_sessions.py +782 -0
  38. package/vendor/agent-frontend-shell/api/auth.py +592 -0
  39. package/vendor/agent-frontend-shell/api/background.py +87 -0
  40. package/vendor/agent-frontend-shell/api/clarify.py +238 -0
  41. package/vendor/agent-frontend-shell/api/commands.py +124 -0
  42. package/vendor/agent-frontend-shell/api/compression_anchor.py +134 -0
  43. package/vendor/agent-frontend-shell/api/config.py +5178 -0
  44. package/vendor/agent-frontend-shell/api/dashboard_probe.py +255 -0
  45. package/vendor/agent-frontend-shell/api/extensions.py +253 -0
  46. package/vendor/agent-frontend-shell/api/gateway_chat.py +435 -0
  47. package/vendor/agent-frontend-shell/api/gateway_watcher.py +230 -0
  48. package/vendor/agent-frontend-shell/api/goals.py +608 -0
  49. package/vendor/agent-frontend-shell/api/helpers.py +474 -0
  50. package/vendor/agent-frontend-shell/api/kanban_bridge.py +1255 -0
  51. package/vendor/agent-frontend-shell/api/metering.py +194 -0
  52. package/vendor/agent-frontend-shell/api/models.py +4210 -0
  53. package/vendor/agent-frontend-shell/api/oauth.py +770 -0
  54. package/vendor/agent-frontend-shell/api/onboarding.py +1046 -0
  55. package/vendor/agent-frontend-shell/api/passkeys.py +365 -0
  56. package/vendor/agent-frontend-shell/api/profiles.py +1499 -0
  57. package/vendor/agent-frontend-shell/api/providers.py +2175 -0
  58. package/vendor/agent-frontend-shell/api/request_diagnostics.py +160 -0
  59. package/vendor/agent-frontend-shell/api/rollback.py +320 -0
  60. package/vendor/agent-frontend-shell/api/routes.py +13990 -0
  61. package/vendor/agent-frontend-shell/api/run_journal.py +284 -0
  62. package/vendor/agent-frontend-shell/api/runner_client.py +156 -0
  63. package/vendor/agent-frontend-shell/api/runtime_adapter.py +431 -0
  64. package/vendor/agent-frontend-shell/api/session_discoverability.py +640 -0
  65. package/vendor/agent-frontend-shell/api/session_events.py +45 -0
  66. package/vendor/agent-frontend-shell/api/session_lifecycle.py +208 -0
  67. package/vendor/agent-frontend-shell/api/session_ops.py +207 -0
  68. package/vendor/agent-frontend-shell/api/session_recovery.py +655 -0
  69. package/vendor/agent-frontend-shell/api/skill_usage.py +32 -0
  70. package/vendor/agent-frontend-shell/api/startup.py +128 -0
  71. package/vendor/agent-frontend-shell/api/state_sync.py +187 -0
  72. package/vendor/agent-frontend-shell/api/streaming.py +7048 -0
  73. package/vendor/agent-frontend-shell/api/system_health.py +167 -0
  74. package/vendor/agent-frontend-shell/api/terminal.py +410 -0
  75. package/vendor/agent-frontend-shell/api/turn_journal.py +214 -0
  76. package/vendor/agent-frontend-shell/api/updates.py +1261 -0
  77. package/vendor/agent-frontend-shell/api/upload.py +322 -0
  78. package/vendor/agent-frontend-shell/api/usage.py +26 -0
  79. package/vendor/agent-frontend-shell/api/workspace.py +867 -0
  80. package/vendor/agent-frontend-shell/api/workspace_git.py +1261 -0
  81. package/vendor/agent-frontend-shell/api/worktrees.py +357 -0
  82. package/vendor/agent-frontend-shell/bootstrap.py +492 -0
  83. package/vendor/agent-frontend-shell/ctl.sh +427 -0
  84. package/vendor/agent-frontend-shell/docker-compose.custom.yml +26 -0
  85. package/vendor/agent-frontend-shell/docker-compose.three-container.yml +168 -0
  86. package/vendor/agent-frontend-shell/docker-compose.two-container.yml +147 -0
  87. package/vendor/agent-frontend-shell/docker-compose.yml +57 -0
  88. package/vendor/agent-frontend-shell/docker_init.bash +459 -0
  89. package/vendor/agent-frontend-shell/docs/CONTRACTS.md +207 -0
  90. package/vendor/agent-frontend-shell/docs/EXTENSIONS.md +212 -0
  91. package/vendor/agent-frontend-shell/docs/ISSUES.md +23 -0
  92. package/vendor/agent-frontend-shell/docs/UIUX-GUIDE.md +196 -0
  93. package/vendor/agent-frontend-shell/docs/advanced-chat-setup.md +83 -0
  94. package/vendor/agent-frontend-shell/docs/docker.md +337 -0
  95. package/vendor/agent-frontend-shell/docs/onboarding-agent-checklist.md +207 -0
  96. package/vendor/agent-frontend-shell/docs/onboarding.md +202 -0
  97. package/vendor/agent-frontend-shell/docs/remote-access.md +75 -0
  98. package/vendor/agent-frontend-shell/docs/rfcs/README.md +53 -0
  99. package/vendor/agent-frontend-shell/docs/rfcs/agent-source-boundary.md +70 -0
  100. package/vendor/agent-frontend-shell/docs/rfcs/canonical-session-resolution.md +124 -0
  101. package/vendor/agent-frontend-shell/docs/rfcs/hermes-run-adapter-contract.md +1079 -0
  102. package/vendor/agent-frontend-shell/docs/rfcs/turn-journal.md +195 -0
  103. package/vendor/agent-frontend-shell/docs/rfcs/webui-run-state-consistency-contract.md +157 -0
  104. package/vendor/agent-frontend-shell/docs/supervisor.md +280 -0
  105. package/vendor/agent-frontend-shell/docs/troubleshooting.md +132 -0
  106. package/vendor/agent-frontend-shell/docs/ui-ux/index.html +863 -0
  107. package/vendor/agent-frontend-shell/docs/ui-ux/two-stage-proposal.html +768 -0
  108. package/vendor/agent-frontend-shell/docs/why-hermes.md +489 -0
  109. package/vendor/agent-frontend-shell/docs/workspace-git.md +92 -0
  110. package/vendor/agent-frontend-shell/docs/wsl-autostart.md +126 -0
  111. package/vendor/agent-frontend-shell/eslint.runtime-guard.config.mjs +35 -0
  112. package/vendor/agent-frontend-shell/extensions/bitseek-design-system.md +330 -0
  113. package/vendor/agent-frontend-shell/extensions/branding/assets/apple-touch-icon.png +0 -0
  114. package/vendor/agent-frontend-shell/extensions/branding/assets/empty-logo.svg +739 -0
  115. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-192.png +0 -0
  116. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-32.png +0 -0
  117. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.png +0 -0
  118. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon-512.svg +745 -0
  119. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.ico +0 -0
  120. package/vendor/agent-frontend-shell/extensions/branding/assets/favicon.svg +745 -0
  121. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v2.svg +751 -0
  122. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon-v3.svg +739 -0
  123. package/vendor/agent-frontend-shell/extensions/branding/assets/titlebar-icon.svg +745 -0
  124. package/vendor/agent-frontend-shell/extensions/branding/branding.js +112 -0
  125. package/vendor/agent-frontend-shell/extensions/branding/config.json +14 -0
  126. package/vendor/agent-frontend-shell/extensions/branding/manifest.json +53 -0
  127. package/vendor/agent-frontend-shell/extensions/index.js +67 -0
  128. package/vendor/agent-frontend-shell/extensions/loader/hermes-loader.js +77 -0
  129. package/vendor/agent-frontend-shell/extensions/manifest.json +16 -0
  130. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.css +333 -0
  131. package/vendor/agent-frontend-shell/extensions/pages/ai-teammates/page.js +487 -0
  132. package/vendor/agent-frontend-shell/extensions/pages/manifest.json +6 -0
  133. package/vendor/agent-frontend-shell/extensions/pages/registry.css +56 -0
  134. package/vendor/agent-frontend-shell/extensions/pages/registry.js +302 -0
  135. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.css +93 -0
  136. package/vendor/agent-frontend-shell/extensions/themes/bitseek/index.js +98 -0
  137. package/vendor/agent-frontend-shell/install.sh +63 -0
  138. package/vendor/agent-frontend-shell/mcp_server.py +567 -0
  139. package/vendor/agent-frontend-shell/package.json +12 -0
  140. package/vendor/agent-frontend-shell/pyproject.toml +56 -0
  141. package/vendor/agent-frontend-shell/pytest.ini +3 -0
  142. package/vendor/agent-frontend-shell/requirements.txt +5 -0
  143. package/vendor/agent-frontend-shell/server.py +624 -0
  144. package/vendor/agent-frontend-shell/start.ps1 +210 -0
  145. package/vendor/agent-frontend-shell/start.sh +65 -0
  146. package/vendor/agent-frontend-shell/static/apple-touch-icon.png +0 -0
  147. package/vendor/agent-frontend-shell/static/boot.js +1990 -0
  148. package/vendor/agent-frontend-shell/static/commands.js +1402 -0
  149. package/vendor/agent-frontend-shell/static/favicon-192.png +0 -0
  150. package/vendor/agent-frontend-shell/static/favicon-32.png +0 -0
  151. package/vendor/agent-frontend-shell/static/favicon-512.png +0 -0
  152. package/vendor/agent-frontend-shell/static/favicon-512.svg +18 -0
  153. package/vendor/agent-frontend-shell/static/favicon.ico +0 -0
  154. package/vendor/agent-frontend-shell/static/favicon.svg +20 -0
  155. package/vendor/agent-frontend-shell/static/i18n.js +15389 -0
  156. package/vendor/agent-frontend-shell/static/icons.js +92 -0
  157. package/vendor/agent-frontend-shell/static/index.html +1506 -0
  158. package/vendor/agent-frontend-shell/static/login.js +177 -0
  159. package/vendor/agent-frontend-shell/static/manifest.json +53 -0
  160. package/vendor/agent-frontend-shell/static/messages.js +3521 -0
  161. package/vendor/agent-frontend-shell/static/onboarding.js +800 -0
  162. package/vendor/agent-frontend-shell/static/panels.js +7995 -0
  163. package/vendor/agent-frontend-shell/static/pwa-startup.js +83 -0
  164. package/vendor/agent-frontend-shell/static/sessions.js +5165 -0
  165. package/vendor/agent-frontend-shell/static/style.css +4774 -0
  166. package/vendor/agent-frontend-shell/static/sw.js +173 -0
  167. package/vendor/agent-frontend-shell/static/terminal.js +632 -0
  168. package/vendor/agent-frontend-shell/static/ui.js +8997 -0
  169. package/vendor/agent-frontend-shell/static/vendor/js-yaml/4.1.0/js-yaml.min.js +2 -0
  170. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.ttf +0 -0
  171. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff +0 -0
  172. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  173. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  174. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  175. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  176. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  177. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  178. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  179. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  180. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  181. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  182. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  183. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  184. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  185. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.ttf +0 -0
  186. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff +0 -0
  187. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Bold.woff2 +0 -0
  188. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  189. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  190. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  191. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.ttf +0 -0
  192. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff +0 -0
  193. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Italic.woff2 +0 -0
  194. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.ttf +0 -0
  195. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff +0 -0
  196. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Main-Regular.woff2 +0 -0
  197. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  198. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  199. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  200. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.ttf +0 -0
  201. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff +0 -0
  202. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Math-Italic.woff2 +0 -0
  203. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  204. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  205. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  206. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  207. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  208. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  209. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  210. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  211. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  212. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.ttf +0 -0
  213. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff +0 -0
  214. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Script-Regular.woff2 +0 -0
  215. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.ttf +0 -0
  216. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff +0 -0
  217. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  218. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.ttf +0 -0
  219. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff +0 -0
  220. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  221. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.ttf +0 -0
  222. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff +0 -0
  223. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  224. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.ttf +0 -0
  225. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff +0 -0
  226. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  227. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  228. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  229. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  230. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.css +1 -0
  231. package/vendor/agent-frontend-shell/static/vendor/katex/0.16.22/katex.min.js +1 -0
  232. package/vendor/agent-frontend-shell/static/vendor/smd.min.js +29 -0
  233. 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