diff --git a/CHANGELOG.md b/CHANGELOG.md index e4091cb..9c8ac50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ --- +## [v0.36] Self-Update Checker with One-Click Update +*April 5, 2026 | 433 tests* + +### Features +- **Update checker.** Non-blocking background check on boot detects when the + WebUI or hermes-agent git repos are behind upstream. Blue banner shows + "WebUI: N updates, Agent: N updates available" with Update Now / Later. +- **One-click update.** "Update Now" runs `git stash && git pull --ff-only && + git stash pop` on each behind repo, then reloads the page. Concurrent update + attempts blocked via lock. Dirty working trees safely stashed and restored. +- **Settings toggle.** "Check for updates" checkbox in Settings panel. Persisted + server-side. Disabled = no background fetch, no banner. +- **30-minute cache.** Git fetch runs at most twice per hour regardless of tab + count. Results cached server-side with TTL. +- **Session-scoped dismissal.** "Later" dismisses banner for the current tab + session (sessionStorage). New tabs get a fresh check. +- **Test mode.** `?test_updates=1` URL param shows the banner with fake data + (localhost only) for UI testing without needing to actually be behind. + +### Architecture +- New `api/updates.py`: `check_for_updates()`, `apply_update()`. Thread-safe + caching with `_cache_lock`. Concurrent apply blocked with `_apply_lock`. + Default branch auto-detected (master/main). +- `api/routes.py`: `GET /api/updates/check`, `POST /api/updates/apply`. + Simulate endpoint gated to 127.0.0.1. +- `static/ui.js`: `_showUpdateBanner()`, `dismissUpdate()`, `applyUpdates()`. +- `static/boot.js`: fire-and-forget check on boot (does not block UI). +- `api/config.py`: `check_for_updates` in settings defaults + bool keys. +- Docker safe: all git ops gated by `.git` directory existence check. + +--- + ## [v0.35.1] Model dropdown fixes *April 5, 2026 | 433 tests* @@ -1236,4 +1268,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel. --- -*Last updated: v0.34, April 5, 2026 | Tests: 433* +*Last updated: v0.36, April 5, 2026 | Tests: 433* diff --git a/SPRINTS.md b/SPRINTS.md index 5f37bcd..fa38251 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -1,6 +1,6 @@ # Hermes Web UI -- Forward Sprint Plan -> Current state: v0.35 | 433 tests | Daily driver ready +> Current state: v0.36 | 433 tests | Daily driver ready > This document plans the path from here to two targets: > > Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the @@ -1156,6 +1156,6 @@ New test cases in `tests/test_sprint26.py`: --- *Last updated: April 5, 2026* -*Current version: v0.35 | 433 tests* +*Current version: v0.36 | 433 tests* *Next sprint: Sprint 24 (Web Polish + Bug Fix Pass)* *Horizon sprint: Sprint 25 (macOS Desktop Application)* 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..5fc7ffa 100644 --- a/api/routes.py +++ b/api/routes.py @@ -227,6 +227,22 @@ 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}) + qs = parse_qs(parsed.query) + force = qs.get('force', ['0'])[0] == '1' + # ?simulate=1 returns fake behind counts for UI testing (localhost only) + if qs.get('simulate', ['0'])[0] == '1' and handler.client_address[0] == '127.0.0.1': + return j(handler, { + 'webui': {'name': 'webui', 'behind': 3, 'current_sha': 'abc1234', 'latest_sha': 'def5678', 'branch': 'master'}, + 'agent': {'name': 'agent', 'behind': 1, 'current_sha': 'aaa0001', 'latest_sha': 'bbb0002', 'branch': 'master'}, + 'checked_at': 0, + }) + 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 +616,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..f59da6e --- /dev/null +++ b/api/updates.py @@ -0,0 +1,164 @@ +""" +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 +_apply_lock = threading.Lock() # prevents concurrent stash/pull/pop on same repo +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 not _apply_lock.acquire(blocking=False): + return {'ok': False, 'message': 'Update already in progress'} + try: + return _apply_update_inner(target) + finally: + _apply_lock.release() + + +def _apply_update_inner(target): + """Inner implementation of apply_update, called under _apply_lock.""" + 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..4e74ff8 100644 --- a/static/boot.js +++ b/static/boot.js @@ -308,7 +308,15 @@ 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;_bootSettings={check_for_updates:false};} + // Non-blocking update check (fire-and-forget, once per tab session) + // ?test_updates=1 in URL forces banner display for testing (bypasses sessionStorage guards) + const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1'; + if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){ + const _checkUrl='/api/updates/check'+(_testUpdates?'?simulate=1':''); + api(_checkUrl).then(d=>{if(!_testUpdates)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..6446861 100644 --- a/static/index.html +++ b/static/index.html @@ -14,7 +14,7 @@