fix: surface stale Kanban client recovery
This commit is contained in:
committed by
nesquena-hermes
parent
bc732995c4
commit
bb75707331
@@ -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)
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────────────
|
||||
|
||||
BIN
docs/pr-media/1823/kanban-hard-refresh-diagnostic.png
Normal file
BIN
docs/pr-media/1823/kanban-hard-refresh-diagnostic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
@@ -1189,11 +1189,49 @@ function _kanbanCard(task, status){
|
||||
</article>`;
|
||||
}
|
||||
|
||||
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 `<div class="main-view-empty"><div class="main-view-empty-title">Kanban needs a hard refresh</div><div class="main-view-empty-subtitle">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.</div><button class="btn primary" type="button" onclick="hardRefreshWebUIClient()">Hard refresh now</button><div class="main-view-empty-subtitle">Original error: ${esc(raw || 'not found')}</div></div>`;
|
||||
}
|
||||
const msg = `${esc(t('kanban_unavailable'))}: ${esc(raw)}`;
|
||||
return `<div class="main-view-empty"><div class="main-view-empty-title">${msg}</div></div>`;
|
||||
}
|
||||
|
||||
async function loadKanban(animate){
|
||||
const board = $('kanbanBoard');
|
||||
const list = $('kanbanList');
|
||||
try {
|
||||
if (animate && board) board.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:13px">${esc(t('loading'))}</div>`;
|
||||
// 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 = `<div class="main-view-empty"><div class="main-view-empty-title">${msg}</div></div>`;
|
||||
if (list) list.innerHTML = `<div class="kanban-empty">${msg}</div>`;
|
||||
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
|
||||
|
||||
76
tests/test_issue1823_kanban_not_found.py
Normal file
76
tests/test_issue1823_kanban_not_found.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user