fix: surface stale Kanban client recovery
This commit is contained in:
parent
bc732995c4
commit
bb75707331
6 changed files with 172 additions and 7 deletions
|
|
@ -745,6 +745,18 @@ def _list_boards_payload(parsed):
|
||||||
current = kb.get_current_board()
|
current = kb.get_current_board()
|
||||||
except Exception:
|
except Exception:
|
||||||
current = "default"
|
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 = []
|
out = []
|
||||||
for raw_meta in boards:
|
for raw_meta in boards:
|
||||||
meta = _board_meta_dict(raw_meta)
|
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
|
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:
|
def _clear_stale_stream_state(session) -> bool:
|
||||||
"""Clear persisted streaming flags when the in-memory stream no longer exists.
|
"""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/"):
|
if parsed.path.startswith("/api/kanban/"):
|
||||||
from api.kanban_bridge import handle_kanban_get
|
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":
|
if parsed.path == "/api/wiki/status":
|
||||||
return _handle_llm_wiki_status(handler, parsed)
|
return _handle_llm_wiki_status(handler, parsed)
|
||||||
if parsed.path == "/api/logs":
|
if parsed.path == "/api/logs":
|
||||||
|
|
@ -3414,7 +3429,9 @@ def handle_post(handler, parsed) -> bool:
|
||||||
if parsed.path.startswith("/api/kanban/"):
|
if parsed.path.startswith("/api/kanban/"):
|
||||||
from api.kanban_bridge import handle_kanban_post
|
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":
|
if parsed.path == "/api/dashboard/config":
|
||||||
from api import dashboard_probe
|
from api import dashboard_probe
|
||||||
|
|
||||||
|
|
@ -4590,7 +4607,9 @@ def handle_patch(handler, parsed) -> bool:
|
||||||
if parsed.path.startswith("/api/kanban/"):
|
if parsed.path.startswith("/api/kanban/"):
|
||||||
from api.kanban_bridge import handle_kanban_patch
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -4602,7 +4621,9 @@ def handle_delete(handler, parsed) -> bool:
|
||||||
if parsed.path.startswith("/api/kanban/"):
|
if parsed.path.startswith("/api/kanban/"):
|
||||||
from api.kanban_bridge import handle_kanban_delete
|
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
|
return False
|
||||||
|
|
||||||
# ── GET route helpers ─────────────────────────────────────────────────────────
|
# ── 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>`;
|
</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){
|
async function loadKanban(animate){
|
||||||
const board = $('kanbanBoard');
|
const board = $('kanbanBoard');
|
||||||
const list = $('kanbanList');
|
const list = $('kanbanList');
|
||||||
try {
|
try {
|
||||||
if (animate && board) board.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:13px">${esc(t('loading'))}</div>`;
|
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());
|
const config = await api('/api/kanban/config' + _kanbanBoardQuery());
|
||||||
let assignees = null;
|
let assignees = null;
|
||||||
try { assignees = await api('/api/kanban/assignees' + _kanbanBoardQuery()); } catch(e) { assignees = null; }
|
try { assignees = await api('/api/kanban/assignees' + _kanbanBoardQuery()); } catch(e) { assignees = null; }
|
||||||
|
|
@ -1230,9 +1268,9 @@ async function loadKanban(animate){
|
||||||
_kanbanStartPolling();
|
_kanbanStartPolling();
|
||||||
_kanbanRenderBoard();
|
_kanbanRenderBoard();
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
const msg = `${esc(t('kanban_unavailable'))}: ${esc(e.message || e)}`;
|
const html = _kanbanUnavailableHtml(e);
|
||||||
if (board) board.innerHTML = `<div class="main-view-empty"><div class="main-view-empty-title">${msg}</div></div>`;
|
if (board) board.innerHTML = html;
|
||||||
if (list) list.innerHTML = `<div class="kanban-empty">${msg}</div>`;
|
if (list) list.innerHTML = html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1687,6 +1725,8 @@ async function loadKanbanBoards(){
|
||||||
let active = serverCurrent;
|
let active = serverCurrent;
|
||||||
if (saved && boards.some(b => b.slug === saved)) {
|
if (saved && boards.some(b => b.slug === saved)) {
|
||||||
active = saved;
|
active = saved;
|
||||||
|
} else if (saved) {
|
||||||
|
_kanbanSetSavedBoard('default');
|
||||||
}
|
}
|
||||||
_kanbanCurrentBoard = (active === 'default') ? null : active;
|
_kanbanCurrentBoard = (active === 'default') ? null : active;
|
||||||
// The switcher is visible whenever ≥1 non-default board exists OR the
|
// 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)
|
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):
|
def test_kanban_task_detail_payload_exposes_comments_events_links_and_runs(monkeypatch):
|
||||||
bridge = _load_bridge(monkeypatch)
|
bridge = _load_bridge(monkeypatch)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue