Skip to content

fix(serve,ui): keep the UI session alive across server restarts (cache-proof shell + 401 self-heal) - #668

Merged
padak merged 2 commits into
mainfrom
claude/lucid-antonelli-cfab5b
Aug 23, 2026
Merged

fix(serve,ui): keep the UI session alive across server restarts (cache-proof shell + 401 self-heal)#668
padak merged 2 commits into
mainfrom
claude/lucid-antonelli-cfab5b

Conversation

@padak

@padak padak commented Aug 23, 2026

Copy link
Copy Markdown
Member

Problem

Live-observed during the PR #665 UI audit: kbagent serve --ui authenticates the browser via the HttpOnly kbagent_session cookie set on GET /. After a server restart (new bearer token), a browser reload can serve the cached index.html without contacting the server — no request means no fresh Set-Cookie, so the stale cookie 401s every /api/* call and the SPA silently renders empty lists ("No data") with no auth error anywhere. A forced fetch('/', {cache: 'reload'}) fixed it instantly, which pinpointed the cache as the culprit.

Fix — two complementary layers

Server (server/app.py)GET / and GET /index.html now answer Cache-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 full 200 that 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 a 401 as "cookie may be stale":

  1. re-fetches the shell once with cache: "reload" (bypasses every browser cache layer — the exact hole the bug lived in), single-flight across concurrent 401s,
  2. retries the request exactly once (safe for mutations — a 401 means the request was rejected on auth, not applied),
  3. only if the retry still 401s, dispatches kbagent:session-expired, rendered by a new SessionExpiredBanner in the shell. For SESSION_EXPIRED / SESSION_NOT_FOUND the banner carries the server message verbatim (it names the on-host kbagent auth login remedy); for a plain UNAUTHORIZED it explains the restart in browser-user terms instead of "Invalid Bearer token."

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 / and the one-route fix genuinely covers all load paths, deep links included.

Tests

  • tests/test_serve_ui.py — new TestUiShellCaching: shell answers no-cache on 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 with cache: "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 real EventTarget, no jsdom dependency). vitest run: 42 passed; tsc -b && vite build clean.

Live verification

Against a real kbagent serve --ui --ui-dist web/frontend/dist on this branch:

  1. curl -D - on GET /cache-control: no-cache + set-cookie: kbagent_session=....
  2. Loaded the UI, then restarted the server with a different token; navigating in the already-open tab produced the self-heal trace in the network log: GET /api/doctor → 401, GET / → 200 (fresh cookie), GET /api/doctor → 200 — no manual reload.
  3. Banner renders and dismisses cleanly in the NERD UI (amber strip, Reload + dismiss controls).

Notes

  • No version bump, no changelog entry (per the docs: version bumps move out of feature PRs into dedicated release PRs #648 release process); the docs note in docs/web-server.md is 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.
  • SSE (EventSource) reconnects are out of scope here: the browser retries those itself, and once any REST call has healed the cookie, reconnects authenticate again.

Open in Devin Review

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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 potential issue.

Open in Devin Review

Comment thread src/keboola_agent_cli/server/app.py
…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
padak merged commit a197674 into main Aug 23, 2026
5 checks passed
@padak
padak deleted the claude/lucid-antonelli-cfab5b branch August 23, 2026 20:45
@padak padak mentioned this pull request Aug 23, 2026
10 tasks
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant