feat: self-update checker with one-click update for WebUI + Agent

Shows a blue banner when the webui or hermes-agent git repos are behind
their upstream branches. One-click 'Update Now' button does stash, pull
--ff-only, stash pop, then reloads the page.

Backend (api/updates.py):
- _check_repo(): git fetch + rev-list count with 15s timeout
- check_for_updates(): 30-min server-side cache, thread-safe, skips
  Docker (no .git dir)
- apply_update(): stash (if dirty), pull --ff-only, pop, invalidate cache

Routes:
- GET /api/updates/check -- returns cached {webui, agent} with behind count
- POST /api/updates/apply -- {target: 'webui'|'agent'}

Frontend:
- Blue banner (matches reconnect-banner pattern) with 'Later' / 'Update Now'
- Non-blocking boot check via fire-and-forget .then(), once per tab session
- sessionStorage guards prevent re-checking and re-showing after dismiss

Settings:
- 'Check for updates' checkbox (default: on) -- when off, no git operations
- Removed 'Default Workspace' dropdown to keep settings panel compact

Performance:
- Server cache: git fetch at most 2x/hour regardless of client count
- sessionStorage: one check per browser tab session
- _check_in_progress flag prevents concurrent fetch storms
- Fire-and-forget: does NOT block the boot sequence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nathan Esquenazi
2026-04-05 09:11:44 -07:00
parent 257092d107
commit 8d1b7a1e01
8 changed files with 242 additions and 6 deletions

View File

@@ -227,6 +227,14 @@ def handle_get(handler, parsed) -> bool:
info = git_info_for_workspace(Path(s.workspace))
return j(handler, {'git': info})
if parsed.path == '/api/updates/check':
settings = load_settings()
if not settings.get('check_for_updates', True):
return j(handler, {'disabled': True})
force = parse_qs(parsed.query).get('force', ['0'])[0] == '1'
from api.updates import check_for_updates
return j(handler, check_for_updates(force=force))
if parsed.path == '/api/chat/stream/status':
stream_id = parse_qs(parsed.query).get('stream_id', [''])[0]
return j(handler, {'active': stream_id in STREAMS, 'stream_id': stream_id})
@@ -600,6 +608,14 @@ def handle_post(handler, parsed) -> bool:
if parsed.path == '/api/session/import':
return _handle_session_import(handler, body)
# ── Self-update (POST) ──
if parsed.path == '/api/updates/apply':
target = body.get('target', '')
if target not in ('webui', 'agent'):
return bad(handler, 'target must be "webui" or "agent"')
from api.updates import apply_update
return j(handler, apply_update(target))
# ── CLI session import (POST) ──
if parsed.path == '/api/session/import_cli':
return _handle_session_import_cli(handler, body)