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 @@
+