From 8d1b7a1e01514335a321a62d464ec2f5f25186b1 Mon Sep 17 00:00:00 2001 From: Nathan Esquenazi Date: Sun, 5 Apr 2026 09:11:44 -0700 Subject: [PATCH] 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) --- api/config.py | 3 +- api/routes.py | 16 +++++ api/updates.py | 153 ++++++++++++++++++++++++++++++++++++++++++++++ static/boot.js | 7 ++- static/index.html | 18 ++++-- static/panels.js | 3 + static/style.css | 7 +++ static/ui.js | 41 +++++++++++++ 8 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 api/updates.py diff --git a/api/config.py b/api/config.py index ae3a92e..6742652 100644 --- a/api/config.py +++ b/api/config.py @@ -674,6 +674,7 @@ _SETTINGS_DEFAULTS = { 'show_token_usage': False, # show input/output token badge below assistant messages 'show_cli_sessions': False, # merge CLI sessions from state.db into the sidebar 'sync_to_insights': False, # mirror WebUI token usage to state.db for /insights + 'check_for_updates': True, # check if webui/agent repos are behind upstream 'theme': 'dark', # active UI theme name (no enum gate -- allows custom themes) 'password_hash': None, # SHA-256 hash; None = auth disabled } @@ -694,7 +695,7 @@ _SETTINGS_ALLOWED_KEYS = set(_SETTINGS_DEFAULTS.keys()) - {'password_hash'} _SETTINGS_ENUM_VALUES = { 'send_key': {'enter', 'ctrl+enter'}, } -_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights'} +_SETTINGS_BOOL_KEYS = {'show_token_usage', 'show_cli_sessions', 'sync_to_insights', 'check_for_updates'} def save_settings(settings: dict) -> dict: """Save settings to disk. Returns the merged settings. Ignores unknown keys.""" diff --git a/api/routes.py b/api/routes.py index 4c05c82..60b9195 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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) diff --git a/api/updates.py b/api/updates.py new file mode 100644 index 0000000..aa18fe3 --- /dev/null +++ b/api/updates.py @@ -0,0 +1,153 @@ +""" +Hermes Web UI -- Self-update checker. + +Checks if the webui and hermes-agent git repos are behind their upstream +branches. Results are cached server-side (30-min TTL) so git fetch runs +at most twice per hour regardless of client count. + +Skips repos that are not git checkouts (e.g. Docker baked images where +.git does not exist). +""" +import subprocess +import threading +import time +from pathlib import Path + +from api.config import REPO_ROOT + +# Lazy -- may be None if agent not found +try: + from api.config import _AGENT_DIR +except ImportError: + _AGENT_DIR = None + +_update_cache = {'webui': None, 'agent': None, 'checked_at': 0} +_cache_lock = threading.Lock() +_check_in_progress = False +CACHE_TTL = 1800 # 30 minutes + + +def _run_git(args, cwd, timeout=10): + """Run a git command and return (stdout, ok).""" + try: + r = subprocess.run( + ['git'] + args, cwd=str(cwd), capture_output=True, + text=True, timeout=timeout, + ) + return r.stdout.strip(), r.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return '', False + + +def _detect_default_branch(path): + """Detect the remote default branch (master or main).""" + out, ok = _run_git(['symbolic-ref', 'refs/remotes/origin/HEAD'], path) + if ok and out: + # refs/remotes/origin/master -> master + return out.split('/')[-1] + # Fallback: try master, then main + for branch in ('master', 'main'): + _, ok = _run_git(['rev-parse', '--verify', f'origin/{branch}'], path) + if ok: + return branch + return 'master' + + +def _check_repo(path, name): + """Check if a git repo is behind its upstream. Returns dict or None.""" + if path is None or not (path / '.git').exists(): + return None + + # Fetch latest from origin (network call, cached by TTL) + _, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15) + if not fetch_ok: + return {'name': name, 'behind': 0, 'error': 'fetch failed'} + + branch = _detect_default_branch(path) + + # Count commits behind + out, ok = _run_git(['rev-list', '--count', f'HEAD..origin/{branch}'], path) + behind = int(out) if ok and out.isdigit() else 0 + + # Get short SHAs for display + current, _ = _run_git(['rev-parse', '--short', 'HEAD'], path) + latest, _ = _run_git(['rev-parse', '--short', f'origin/{branch}'], path) + + return { + 'name': name, + 'behind': behind, + 'current_sha': current, + 'latest_sha': latest, + 'branch': branch, + } + + +def check_for_updates(force=False): + """Return cached update status for webui and agent repos.""" + global _check_in_progress + with _cache_lock: + if not force and time.time() - _update_cache['checked_at'] < CACHE_TTL: + return dict(_update_cache) + if _check_in_progress: + return dict(_update_cache) # another thread is already checking + _check_in_progress = True + + try: + # Run checks outside the lock (network I/O) + webui_info = _check_repo(REPO_ROOT, 'webui') + agent_info = _check_repo(_AGENT_DIR, 'agent') + + with _cache_lock: + _update_cache['webui'] = webui_info + _update_cache['agent'] = agent_info + _update_cache['checked_at'] = time.time() + return dict(_update_cache) + finally: + _check_in_progress = False + + +def apply_update(target): + """Stash, pull --ff-only, pop for the given target repo.""" + if target == 'webui': + path = REPO_ROOT + elif target == 'agent': + path = _AGENT_DIR + else: + return {'ok': False, 'message': f'Unknown target: {target}'} + + if path is None or not (path / '.git').exists(): + return {'ok': False, 'message': 'Not a git repository'} + + branch = _detect_default_branch(path) + + # Check for dirty working tree + status_out, _ = _run_git(['status', '--porcelain'], path) + stashed = False + if status_out: + _, ok = _run_git(['stash'], path) + if not ok: + return {'ok': False, 'message': 'Failed to stash local changes'} + stashed = True + + # Pull with ff-only (no merge commits) + pull_out, pull_ok = _run_git(['pull', '--ff-only', 'origin', branch], path, timeout=30) + if not pull_ok: + if stashed: + _run_git(['stash', 'pop'], path) + return {'ok': False, 'message': f'Pull failed: {pull_out[:200]}'} + + # Pop stash if we stashed + if stashed: + _, pop_ok = _run_git(['stash', 'pop'], path) + if not pop_ok: + return { + 'ok': False, + 'message': 'Updated but stash pop failed -- manual merge needed', + 'stash_conflict': True, + } + + # Invalidate cache + with _cache_lock: + _update_cache['checked_at'] = 0 + + return {'ok': True, 'message': f'{target} updated successfully', 'target': target} diff --git a/static/boot.js b/static/boot.js index e59cb82..e5a8c24 100644 --- a/static/boot.js +++ b/static/boot.js @@ -308,7 +308,12 @@ document.querySelectorAll('.suggestion').forEach(btn=>{ (async()=>{ // Load send key preference - try{const s=await api('/api/settings');window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;} + let _bootSettings={}; + try{const s=await api('/api/settings');_bootSettings=s;window._sendKey=s.send_key||'enter';window._showTokenUsage=!!s.show_token_usage;window._showCliSessions=!!s.show_cli_sessions;const _theme=s.theme||'dark';document.documentElement.dataset.theme=_theme;localStorage.setItem('hermes-theme',_theme);}catch(e){window._sendKey='enter';window._showTokenUsage=false;window._showCliSessions=false;} + // Non-blocking update check (fire-and-forget, once per tab session) + if(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed')){ + api('/api/updates/check').then(d=>{sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{}); + } // Fetch active profile try{const p=await api('/api/profile/active');S.activeProfile=p.name||'default';}catch(e){S.activeProfile='default';} // Update profile chip label immediately diff --git a/static/index.html b/static/index.html index 2b7595b..b848ca1 100644 --- a/static/index.html +++ b/static/index.html @@ -203,6 +203,13 @@
+
+ +
+ + +
+
⚠ A response may have been in progress when you last left. Reload messages?
@@ -319,10 +326,6 @@
-
- - -
+ Check for updates + +
Show a banner when newer versions of the WebUI or Agent are available. Runs a background git fetch periodically.
+
Enter a new password to set or change it. Leave blank to keep current setting.
diff --git a/static/panels.js b/static/panels.js index 85d4eed..65b3293 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1022,6 +1022,8 @@ async function loadSettingsPanel(){ if(showCliCb){showCliCb.checked=!!settings.show_cli_sessions;showCliCb.addEventListener('change',_markSettingsDirty,{once:false});} const syncCb=$('settingsSyncInsights'); if(syncCb){syncCb.checked=!!settings.sync_to_insights;syncCb.addEventListener('change',_markSettingsDirty,{once:false});} + const updateCb=$('settingsCheckUpdates'); + if(updateCb){updateCb.checked=settings.check_for_updates!==false;updateCb.addEventListener('change',_markSettingsDirty,{once:false});} // Password field: always blank (we don't send hash back) const pwField=$('settingsPassword'); if(pwField){pwField.value='';pwField.addEventListener('input',_markSettingsDirty,{once:false});} @@ -1055,6 +1057,7 @@ async function saveSettings(andClose){ body.show_token_usage=showTokenUsage; body.show_cli_sessions=showCliSessions; body.sync_to_insights=!!($('settingsSyncInsights')||{}).checked; + body.check_for_updates=!!($('settingsCheckUpdates')||{}).checked; // Password: only act if the field has content; blank = leave auth unchanged if(pw && pw.trim()){ try{ diff --git a/static/style.css b/static/style.css index bae06d6..e305db8 100644 --- a/static/style.css +++ b/static/style.css @@ -145,6 +145,13 @@ .reconnect-banner.visible{display:flex;} .reconnect-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(201,168,76,0.15);border:1px solid rgba(201,168,76,0.4);color:var(--gold);cursor:pointer;} .reconnect-btn:hover{background:rgba(201,168,76,0.25);} + /* ── Update banner ── */ + .update-banner{display:none;background:var(--surface);border:1px solid rgba(124,185,255,0.4);border-radius:10px;padding:10px 16px;margin:10px auto;max-width:780px;font-size:13px;color:var(--blue);align-items:center;justify-content:space-between;gap:12px;} + .update-banner.visible{display:flex;} + .update-btn{padding:5px 12px;border-radius:7px;font-size:12px;font-weight:600;background:rgba(124,185,255,0.1);border:1px solid rgba(124,185,255,0.3);color:var(--blue);cursor:pointer;transition:background .15s;} + .update-btn:hover{background:rgba(124,185,255,0.2);} + .update-primary{background:rgba(124,185,255,0.2);border-color:rgba(124,185,255,0.5);} + .update-btn:disabled{opacity:0.5;cursor:not-allowed;} /* ── Approval card ── */ .approval-card{display:none;max-width:780px;margin:0 auto 0;padding:0 20px 12px;} .approval-card.visible{display:block;} diff --git a/static/ui.js b/static/ui.js index 0cb7b54..fe78f19 100644 --- a/static/ui.js +++ b/static/ui.js @@ -334,6 +334,47 @@ async function refreshSession() { showToast('Conversation refreshed'); } catch(e) { setStatus('Refresh failed: ' + e.message); } } +// ── Update banner ── +function _showUpdateBanner(data){ + const parts=[]; + if(data.webui&&data.webui.behind>0) parts.push(`WebUI: ${data.webui.behind} update${data.webui.behind>1?'s':''}`); + if(data.agent&&data.agent.behind>0) parts.push(`Agent: ${data.agent.behind} update${data.agent.behind>1?'s':''}`); + if(!parts.length)return; + const msg=$('updateMsg'); + if(msg) msg.textContent='\u2B06 '+parts.join(', ')+' available'; + const banner=$('updateBanner'); + if(banner) banner.classList.add('visible'); + window._updateData=data; +} +function dismissUpdate(){ + const b=$('updateBanner');if(b)b.classList.remove('visible'); + sessionStorage.setItem('hermes-update-dismissed','1'); +} +async function applyUpdates(){ + const btn=$('btnApplyUpdate'); + if(btn){btn.disabled=true;btn.textContent='Updating\u2026';} + const targets=[]; + if(window._updateData?.webui?.behind>0) targets.push('webui'); + if(window._updateData?.agent?.behind>0) targets.push('agent'); + try{ + for(const target of targets){ + const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target})}); + if(!res.ok){ + showToast('Update failed ('+target+'): '+(res.message||'unknown error')); + if(btn){btn.disabled=false;btn.textContent='Update Now';} + return; + } + } + showToast('Updated! Reloading\u2026'); + sessionStorage.removeItem('hermes-update-checked'); + sessionStorage.removeItem('hermes-update-dismissed'); + setTimeout(()=>location.reload(),1500); + }catch(e){ + showToast('Update failed: '+e.message); + if(btn){btn.disabled=false;btn.textContent='Update Now';} + } +} + async function checkInflightOnBoot(sid) { const raw = localStorage.getItem(INFLIGHT_KEY); if (!raw) return;