fix(serve,ui): keep the UI session alive across server restarts (cache-proof shell + 401 self-heal) - #668
Merged
Merged
Conversation
Live-observed during the PR #665 UI audit: after a kbagent serve --ui restart (new bearer token), a browser reload could serve the cached index.html without contacting the server. No request means no fresh Set-Cookie, so the stale kbagent_session cookie 401'd every /api/* call and the SPA silently rendered empty lists with no visible error. Two complementary layers: - Server: GET / and GET /index.html now answer Cache-Control: no-cache, so the browser revalidates the shell on every load -- and since the bootstrap route implements no conditional handling, revalidation is always a full 200 that re-sets the session cookie. Scoped to the shell only; hashed build assets keep their default caching. - SPA: the API client treats a 401 as a possibly-stale cookie. It re-fetches the shell once with cache: 'reload' (single-flight across concurrent 401s), retries the request, and only if the retry still 401s dispatches kbagent:session-expired -- rendered by a new SessionExpiredBanner in the shell instead of failing silently. For SESSION_EXPIRED / SESSION_NOT_FOUND the banner carries the server message, which names the on-host 'kbagent auth login' remedy. Also corrects the _install_ui docstrings: Starlette's html=True not-found fallback serves 404.html (absent from a Vite build), not index.html -- the SPA is hash-routed, so every shell load goes through GET /. Verified live: restarted serve --ui with a new token; the deployed client healed a 401 via GET / re-bootstrap and retried to 200 without a manual reload.
…ted reality Devin Review on #668 caught the third stale docstring: it still described client-side routes resolving to index.html via the StaticFiles html=True fallback. The predicate's behavior is unchanged -- unmatched paths stay auth-public -- only the description now matches what actually happens (hash-routed SPA, unknown paths 404 from the static mount).
padak
added a commit
that referenced
this pull request
Aug 23, 2026
* chore(release): 0.90.0 Bumps pyproject.toml to 0.90.0 and adds the changelog entry covering every PR merged since v0.89.0 (#658, #662, #661, #663, #665, #666, #664, #668, #667, #623), resolves the vNEXT placeholders those PRs left behind, and adds the curated What's new reel for the release. * docs(web-server): keep the What's-new anchor stable across releases The '### What's-new popup *(since vNEXT)*' heading put the version gate in the heading itself, so resolving the placeholder to 0.90.0 changed the generated slug to 'whats-new-popup-since-0900' and broke the in-page link at line 138 -- and would have broken it again on every future release. Moved the '(since 0.90.0)' tag to the first body line: the anchor is now the stable 'whats-new-popup', the gate stays visible, and check_version_gates.py still sees it (it scans the whole file, not just headings).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Live-observed during the PR #665 UI audit:
kbagent serve --uiauthenticates the browser via the HttpOnlykbagent_sessioncookie set onGET /. After a server restart (new bearer token), a browser reload can serve the cachedindex.htmlwithout contacting the server — no request means no freshSet-Cookie, so the stale cookie 401s every/api/*call and the SPA silently renders empty lists ("No data") with no auth error anywhere. A forcedfetch('/', {cache: 'reload'})fixed it instantly, which pinpointed the cache as the culprit.Fix — two complementary layers
Server (
server/app.py) —GET /andGET /index.htmlnow answerCache-Control: no-cache, so the browser revalidates the shell on every load. The bootstrap route implements no conditional-request handling, so revalidation is always a full200that re-sets the session cookie. Scoped to the shell only: hashed build assets keep their default caching (pinned by test).SPA (
web/frontend/src/api/client.ts) — the API client treats a401as "cookie may be stale":cache: "reload"(bypasses every browser cache layer — the exact hole the bug lived in), single-flight across concurrent 401s,kbagent:session-expired, rendered by a newSessionExpiredBannerin the shell. ForSESSION_EXPIRED/SESSION_NOT_FOUNDthe banner carries the server message verbatim (it names the on-hostkbagent auth loginremedy); for a plainUNAUTHORIZEDit explains the restart in browser-user terms instead of "Invalid Bearer token."Also corrects the
_install_uidocstrings: Starlette'shtml=Truenot-found fallback serves404.html(absent from a Vite build), notindex.html— the SPA is hash-routed, so every shell load goes throughGET /and the one-route fix genuinely covers all load paths, deep links included.Tests
tests/test_serve_ui.py— newTestUiShellCaching: shell answersno-cacheon both routes; assets are explicitly not stamped. TDD (watched red first). Full suite: 6024 passed, 181 skipped.web/frontend/src/api/client.test.ts— new vitest coverage for the retry path: retry-once-after-401 withcache: "reload"+credentials: "include", session-expired event after a failed retry (and exactly one shell fetch — no retry loop), non-401 errors untouched, single-flight across concurrent 401s. Runs in the default node environment (window stubbed with a realEventTarget, no jsdom dependency).vitest run: 42 passed;tsc -b && vite buildclean.Live verification
Against a real
kbagent serve --ui --ui-dist web/frontend/diston this branch:curl -D -onGET /→cache-control: no-cache+set-cookie: kbagent_session=....GET /api/doctor → 401,GET / → 200(fresh cookie),GET /api/doctor → 200— no manual reload.Notes
docs/web-server.mdis tagged(since vNEXT).src/keboola_agent_cli/_ui_dist/is gitignored and rebuilt by the wheel build hook, so the frontend change ships with the next release automatically.EventSource) reconnects are out of scope here: the browser retries those itself, and once any REST call has healed the cookie, reconnects authenticate again.