- api/updates.py: add _apply_lock to prevent concurrent stash/pull/pop - static/boot.js: set check_for_updates:false on settings fetch failure - static/panels.js: remove dead settingsWorkspace references (element removed from HTML) - api/routes.py + static/boot.js: add ?test_updates=1 URL param for testing banner without being behind on git (localhost-only simulate endpoint)
165 lines
5.2 KiB
Python
165 lines
5.2 KiB
Python
"""
|
|
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}
|