diff --git a/api/kanban_bridge.py b/api/kanban_bridge.py index 403f2710..d758d372 100644 --- a/api/kanban_bridge.py +++ b/api/kanban_bridge.py @@ -745,6 +745,18 @@ def _list_boards_payload(parsed): current = kb.get_current_board() except Exception: current = "default" + visible_slugs = {(_board_meta_dict(meta).get("slug")) for meta in boards} + default_slug = getattr(kb, "DEFAULT_BOARD", "default") + if current not in visible_slugs: + # The on-disk active-board pointer can outlive an archived/deleted board + # when another CLI/WebUI process removes it. Surface a valid current + # board instead of letting the frontend pin every subsequent request to + # a ghost slug and fail with an opaque 404. + try: + kb.clear_current_board() + except Exception: + pass + current = default_slug out = [] for raw_meta in boards: meta = _board_meta_dict(raw_meta) diff --git a/api/routes.py b/api/routes.py index e086587a..18d9c1a5 100644 --- a/api/routes.py +++ b/api/routes.py @@ -590,6 +590,19 @@ from api.agent_health import build_agent_health_payload from api.system_health import build_system_health_payload +def _kanban_unknown_endpoint(handler, parsed, method: str) -> bool: + """Return a Kanban-specific 404 for stale clients/obsolete endpoint shapes.""" + return bad( + handler, + ( + f"unknown Kanban endpoint: {method} {parsed.path}. " + "If this appeared after a WebUI update, your browser may be running " + "a stale cached bundle; use Hard refresh now, then reopen Kanban." + ), + status=404, + ) or True + + def _clear_stale_stream_state(session) -> bool: """Clear persisted streaming flags when the in-memory stream no longer exists. @@ -2616,7 +2629,9 @@ def handle_get(handler, parsed) -> bool: if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_get - return handle_kanban_get(handler, parsed) + if handle_kanban_get(handler, parsed): + return True + return _kanban_unknown_endpoint(handler, parsed, "GET") if parsed.path == "/api/wiki/status": return _handle_llm_wiki_status(handler, parsed) if parsed.path == "/api/logs": @@ -3414,7 +3429,9 @@ def handle_post(handler, parsed) -> bool: if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_post - return handle_kanban_post(handler, parsed, body) + if handle_kanban_post(handler, parsed, body): + return True + return _kanban_unknown_endpoint(handler, parsed, "POST") if parsed.path == "/api/dashboard/config": from api import dashboard_probe @@ -4590,7 +4607,9 @@ def handle_patch(handler, parsed) -> bool: if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_patch - return handle_kanban_patch(handler, parsed, body) + if handle_kanban_patch(handler, parsed, body): + return True + return _kanban_unknown_endpoint(handler, parsed, "PATCH") return False @@ -4602,7 +4621,9 @@ def handle_delete(handler, parsed) -> bool: if parsed.path.startswith("/api/kanban/"): from api.kanban_bridge import handle_kanban_delete - return handle_kanban_delete(handler, parsed, body) + if handle_kanban_delete(handler, parsed, body): + return True + return _kanban_unknown_endpoint(handler, parsed, "DELETE") return False # ── GET route helpers ───────────────────────────────────────────────────────── diff --git a/docs/pr-media/1823/kanban-hard-refresh-diagnostic.png b/docs/pr-media/1823/kanban-hard-refresh-diagnostic.png new file mode 100644 index 00000000..94546c3e Binary files /dev/null and b/docs/pr-media/1823/kanban-hard-refresh-diagnostic.png differ diff --git a/static/panels.js b/static/panels.js index 9b056382..0add57b8 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1189,11 +1189,49 @@ function _kanbanCard(task, status){ `; } +async function hardRefreshWebUIClient(){ + try { + if (navigator.serviceWorker) { + const regs = await navigator.serviceWorker.getRegistrations(); + await Promise.all(regs.map(r => r.unregister())); + } + } catch(_) {} + try { + if (window.caches) { + const keys = await caches.keys(); + await Promise.all(keys.map(k => caches.delete(k))); + } + } catch(_) {} + window.location.reload(); +} + +function _kanbanLooksLikeStaleClientError(err){ + const msg = String((err && err.message) || err || '').toLowerCase(); + return !!(err && err.status === 404 && ( + msg === 'not found' || + msg.includes('unknown kanban endpoint') || + msg.includes('stale cached bundle') + )); +} + +function _kanbanUnavailableHtml(err){ + const raw = String((err && err.message) || err || ''); + if (_kanbanLooksLikeStaleClientError(err)) { + return `
Kanban needs a hard refresh
The server rejected an obsolete Kanban endpoint. This usually means the browser or Mac app is still running a stale cached WebUI bundle after an update.
Original error: ${esc(raw || 'not found')}
`; + } + const msg = `${esc(t('kanban_unavailable'))}: ${esc(raw)}`; + return `
${msg}
`; +} + async function loadKanban(animate){ const board = $('kanbanBoard'); const list = $('kanbanList'); try { if (animate && board) board.innerHTML = `
${esc(t('loading'))}
`; + // Resolve the active board before board-scoped requests. If another CLI or + // tab archived the previous board, /boards can fall back to default instead + // of leaving config/board pinned to a ghost slug. + await loadKanbanBoards(); const config = await api('/api/kanban/config' + _kanbanBoardQuery()); let assignees = null; try { assignees = await api('/api/kanban/assignees' + _kanbanBoardQuery()); } catch(e) { assignees = null; } @@ -1230,9 +1268,9 @@ async function loadKanban(animate){ _kanbanStartPolling(); _kanbanRenderBoard(); } catch(e) { - const msg = `${esc(t('kanban_unavailable'))}: ${esc(e.message || e)}`; - if (board) board.innerHTML = `
${msg}
`; - if (list) list.innerHTML = `
${msg}
`; + const html = _kanbanUnavailableHtml(e); + if (board) board.innerHTML = html; + if (list) list.innerHTML = html; } } @@ -1687,6 +1725,8 @@ async function loadKanbanBoards(){ let active = serverCurrent; if (saved && boards.some(b => b.slug === saved)) { active = saved; + } else if (saved) { + _kanbanSetSavedBoard('default'); } _kanbanCurrentBoard = (active === 'default') ? null : active; // The switcher is visible whenever ≥1 non-default board exists OR the diff --git a/tests/test_issue1823_kanban_not_found.py b/tests/test_issue1823_kanban_not_found.py new file mode 100644 index 00000000..6899994b --- /dev/null +++ b/tests/test_issue1823_kanban_not_found.py @@ -0,0 +1,76 @@ +"""Regression coverage for #1823 Kanban stale-client/board-pointer failures.""" + +from __future__ import annotations + +import io +import json +from types import SimpleNamespace +from urllib.parse import urlparse + +from api import routes + +ROOT = __import__("pathlib").Path(__file__).resolve().parents[1] +PANELS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8") +ROUTES = (ROOT / "api" / "routes.py").read_text(encoding="utf-8") + + +class _FakeHandler: + def __init__(self): + self.status = None + self.headers = {} + self.response_headers = [] + self.wfile = io.BytesIO() + + def send_response(self, status): + self.status = status + + def send_header(self, key, value): + self.response_headers.append((key, value)) + + def end_headers(self): + pass + + def body_json(self): + return json.loads(self.wfile.getvalue().decode("utf-8")) + + +def test_unknown_kanban_endpoint_get_returns_stale_client_diagnostic(): + """Obsolete/stale JS should not collapse to a bare `not found` 404.""" + handler = _FakeHandler() + handled = routes.handle_get(handler, urlparse("/api/kanban/obsolete-shape")) + + assert handled is True + assert handler.status == 404 + error = handler.body_json()["error"] + assert error != "not found" + assert "unknown Kanban endpoint: GET /api/kanban/obsolete-shape" in error + assert "stale cached bundle" in error + assert "Hard refresh now" in error + + +def test_unknown_kanban_endpoint_routes_are_wrapped_for_all_methods(): + assert 'return _kanban_unknown_endpoint(handler, parsed, "GET")' in ROUTES + assert 'return _kanban_unknown_endpoint(handler, parsed, "POST")' in ROUTES + assert 'return _kanban_unknown_endpoint(handler, parsed, "PATCH")' in ROUTES + assert 'return _kanban_unknown_endpoint(handler, parsed, "DELETE")' in ROUTES + + +def test_kanban_stale_client_error_renders_hard_refresh_escape_hatch(): + assert "function _kanbanLooksLikeStaleClientError(err)" in PANELS + assert "err.status === 404" in PANELS + assert "msg === 'not found'" in PANELS + assert "msg.includes('unknown kanban endpoint')" in PANELS + assert "Kanban needs a hard refresh" in PANELS + assert "Hard refresh now" in PANELS + assert "navigator.serviceWorker.getRegistrations()" in PANELS + assert "caches.keys()" in PANELS + assert "window.location.reload()" in PANELS + + +def test_kanban_load_resolves_board_before_board_scoped_requests(): + boards_pos = PANELS.find("await loadKanbanBoards();") + config_pos = PANELS.find("api('/api/kanban/config' + _kanbanBoardQuery())") + assert boards_pos != -1 + assert config_pos != -1 + assert boards_pos < config_pos + assert "_kanbanSetSavedBoard('default');" in PANELS diff --git a/tests/test_kanban_bridge.py b/tests/test_kanban_bridge.py index bbcae21d..3e99956d 100644 --- a/tests/test_kanban_bridge.py +++ b/tests/test_kanban_bridge.py @@ -389,6 +389,22 @@ def test_kanban_board_payload_exposes_read_only_board(monkeypatch): assert any(task["id"] == "t_1" and task["title"] == "Read-only board target" for task in all_tasks) +def test_board_pointer_drift_falls_back_to_default(monkeypatch): + bridge = _load_bridge(monkeypatch) + fake_kanban = sys.modules["hermes_cli.kanban_db"] + fake_kanban.boards = { + "default": {"slug": "default", "name": "Default board", "archived": False}, + "active": {"slug": "active", "name": "Active board", "archived": False}, + } + fake_kanban.set_current_board("ghost") + + data = bridge._list_boards_payload(_parsed(path="/api/kanban/boards")) + + assert data["current"] == "default" + assert fake_kanban.get_current_board() == "default" + assert any(board["slug"] == "default" and board["is_current"] for board in data["boards"]) + + def test_kanban_task_detail_payload_exposes_comments_events_links_and_runs(monkeypatch): bridge = _load_bridge(monkeypatch)