Merge pull request #72 from bunny-lab-io:codex/fix-missing-agent_hashes-in-device-table-9dnq9a

Cache repo hash on server for agent updates
This commit is contained in:
2025-10-05 03:11:56 -06:00
committed by GitHub
3 changed files with 256 additions and 12 deletions

File diff suppressed because one or more lines are too long

View File

@@ -126,17 +126,15 @@ export default function DeviceList({ onSelectDevice }) {
const fetchLatestRepoHash = useCallback(async () => { const fetchLatestRepoHash = useCallback(async () => {
try { try {
const resp = await fetch( const params = new URLSearchParams({ repo: "bunny-lab-io/Borealis", branch: "main" });
"https://api.github.com/repos/bunny-lab-io/Borealis/branches/main", const resp = await fetch(`/api/agent/repo_hash?${params.toString()}`);
{
headers: {
Accept: "application/vnd.github+json",
},
}
);
if (!resp.ok) throw new Error(`GitHub status ${resp.status}`);
const json = await resp.json(); const json = await resp.json();
const sha = (json?.commit?.sha || "").trim(); const sha = (json?.sha || "").trim();
if (!resp.ok || !sha) {
const err = new Error(`Latest hash status ${resp.status}${json?.error ? ` - ${json.error}` : ""}`);
err.response = json;
throw err;
}
setRepoHash((prev) => sha || prev || null); setRepoHash((prev) => sha || prev || null);
return sha || null; return sha || null;
} catch (err) { } catch (err) {

View File

@@ -21,6 +21,7 @@ from typing import List, Dict, Tuple, Optional, Any, Set
import sqlite3 import sqlite3
import io import io
import uuid import uuid
from threading import Lock
from datetime import datetime, timezone from datetime import datetime, timezone
try: try:
@@ -68,6 +69,210 @@ def _write_service_log(service: str, msg: str):
pass pass
_REPO_HEAD_CACHE: Dict[str, Tuple[str, float]] = {}
_REPO_HEAD_LOCK = Lock()
_CACHE_ROOT = os.environ.get('BOREALIS_CACHE_DIR')
if _CACHE_ROOT:
_CACHE_ROOT = os.path.abspath(_CACHE_ROOT)
else:
_CACHE_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), 'cache'))
_REPO_HASH_CACHE_FILE = os.path.join(_CACHE_ROOT, 'repo_hash_cache.json')
_DEFAULT_REPO = os.environ.get('BOREALIS_REPO', 'bunny-lab-io/Borealis')
_DEFAULT_BRANCH = os.environ.get('BOREALIS_REPO_BRANCH', 'main')
try:
_REPO_HASH_INTERVAL = int(os.environ.get('BOREALIS_REPO_HASH_REFRESH', '60'))
except ValueError:
_REPO_HASH_INTERVAL = 60
_REPO_HASH_INTERVAL = max(30, min(_REPO_HASH_INTERVAL, 3600))
_REPO_HASH_WORKER_STARTED = False
_REPO_HASH_WORKER_LOCK = Lock()
def _hydrate_repo_hash_cache_from_disk() -> None:
try:
if not os.path.isfile(_REPO_HASH_CACHE_FILE):
return
with open(_REPO_HASH_CACHE_FILE, 'r', encoding='utf-8') as fh:
payload = json.load(fh)
entries = payload.get('entries') if isinstance(payload, dict) else None
if not isinstance(entries, dict):
return
now = time.time()
with _REPO_HEAD_LOCK:
for key, entry in entries.items():
if not isinstance(entry, dict):
continue
sha = (entry.get('sha') or '').strip()
if not sha:
continue
ts_raw = entry.get('ts')
try:
ts = float(ts_raw)
except (TypeError, ValueError):
ts = now
_REPO_HEAD_CACHE[key] = (sha, ts)
except Exception as exc:
_write_service_log('server', f'failed to hydrate repo hash cache: {exc}')
def _persist_repo_hash_cache() -> None:
snapshot: Dict[str, Tuple[str, float]]
with _REPO_HEAD_LOCK:
snapshot = {
key: (sha, ts)
for key, (sha, ts) in _REPO_HEAD_CACHE.items()
if sha
}
try:
if not snapshot:
try:
os.remove(_REPO_HASH_CACHE_FILE)
except FileNotFoundError:
pass
except Exception as exc:
_write_service_log('server', f'failed to remove repo hash cache file: {exc}')
return
os.makedirs(_CACHE_ROOT, exist_ok=True)
tmp_path = _REPO_HASH_CACHE_FILE + '.tmp'
payload = {
'version': 1,
'entries': {
key: {'sha': sha, 'ts': ts}
for key, (sha, ts) in snapshot.items()
},
}
with open(tmp_path, 'w', encoding='utf-8') as fh:
json.dump(payload, fh)
os.replace(tmp_path, _REPO_HASH_CACHE_FILE)
except Exception as exc:
_write_service_log('server', f'failed to persist repo hash cache: {exc}')
def _fetch_repo_head(owner_repo: str, branch: str = 'main', *, ttl_seconds: int = 60, force_refresh: bool = False) -> Dict[str, Any]:
"""Resolve the latest commit hash for ``owner_repo``/``branch`` via GitHub's REST API.
The server caches the response so that a fleet of agents can reuse the
result without exhausting rate limits. ``ttl_seconds`` bounds how long a
cached value is considered fresh. When ``force_refresh`` is True the cache
is bypassed and a new request is attempted immediately.
"""
key = f"{owner_repo}:{branch}"
now = time.time()
with _REPO_HEAD_LOCK:
cached = _REPO_HEAD_CACHE.get(key)
cached_sha: Optional[str] = None
cached_ts: Optional[float] = None
cached_age: Optional[float] = None
if cached:
cached_sha, cached_ts = cached
cached_age = max(0.0, now - cached_ts)
if cached_sha and not force_refresh and cached_age is not None and cached_age < max(30, ttl_seconds):
return {
'sha': cached_sha,
'cached': True,
'age_seconds': cached_age,
'error': None,
'source': 'cache',
}
headers = {
'Accept': 'application/vnd.github+json',
'User-Agent': 'Borealis-Server'
}
token = os.environ.get('BOREALIS_GITHUB_TOKEN') or os.environ.get('GITHUB_TOKEN')
if token:
headers['Authorization'] = f'Bearer {token}'
error_msg: Optional[str] = None
sha: Optional[str] = None
try:
resp = requests.get(
f'https://api.github.com/repos/{owner_repo}/branches/{branch}',
headers=headers,
timeout=20,
)
if resp.status_code == 200:
data = resp.json()
sha = (data.get('commit') or {}).get('sha')
else:
error_msg = f'GitHub REST API repo head lookup failed: HTTP {resp.status_code} {resp.text[:200]}'
except Exception as exc: # pragma: no cover - defensive logging
error_msg = f'GitHub REST API repo head lookup raised: {exc}'
if sha:
sha = sha.strip()
with _REPO_HEAD_LOCK:
_REPO_HEAD_CACHE[key] = (sha, now)
_persist_repo_hash_cache()
return {
'sha': sha,
'cached': False,
'age_seconds': 0.0,
'error': None,
'source': 'github',
}
if error_msg:
_write_service_log('server', error_msg)
if cached_sha is not None:
return {
'sha': cached_sha,
'cached': True,
'age_seconds': cached_age,
'error': error_msg or 'using cached value',
'source': 'cache-stale',
}
return {
'sha': None,
'cached': False,
'age_seconds': None,
'error': error_msg or 'unable to resolve repository head',
'source': 'github',
}
def _refresh_default_repo_hash(force: bool = False) -> Dict[str, Any]:
ttl = max(30, _REPO_HASH_INTERVAL)
try:
return _fetch_repo_head(_DEFAULT_REPO, _DEFAULT_BRANCH, ttl_seconds=ttl, force_refresh=force)
except Exception as exc: # pragma: no cover - defensive logging
_write_service_log('server', f'default repo hash refresh failed: {exc}')
raise
def _repo_hash_background_worker():
interval = max(30, _REPO_HASH_INTERVAL)
# Fetch immediately, then sleep between refreshes
while True:
try:
_refresh_default_repo_hash(force=True)
except Exception:
# _refresh_default_repo_hash already logs details
pass
eventlet.sleep(interval)
def _ensure_repo_hash_worker():
global _REPO_HASH_WORKER_STARTED
with _REPO_HASH_WORKER_LOCK:
if _REPO_HASH_WORKER_STARTED:
return
_REPO_HASH_WORKER_STARTED = True
try:
eventlet.spawn_n(_repo_hash_background_worker)
except Exception as exc:
_REPO_HASH_WORKER_STARTED = False
_write_service_log('server', f'failed to start repo hash worker: {exc}')
def _ansible_log_server(msg: str): def _ansible_log_server(msg: str):
_write_service_log('ansible', msg) _write_service_log('ansible', msg)
@@ -126,6 +331,9 @@ socketio = SocketIO(
} }
) )
_hydrate_repo_hash_cache_from_disk()
_ensure_repo_hash_worker()
# --------------------------------------------- # ---------------------------------------------
# Serve ReactJS Production Vite Build from dist/ # Serve ReactJS Production Vite Build from dist/
# --------------------------------------------- # ---------------------------------------------
@@ -147,6 +355,44 @@ def serve_dist(path):
def health(): def health():
return jsonify({"status": "ok"}) return jsonify({"status": "ok"})
@app.route("/api/agent/repo_hash", methods=["GET"])
def api_agent_repo_hash():
try:
repo = (request.args.get('repo') or _DEFAULT_REPO).strip()
branch = (request.args.get('branch') or _DEFAULT_BRANCH).strip()
refresh_flag = (request.args.get('refresh') or '').strip().lower()
ttl_raw = request.args.get('ttl')
if '/' not in repo:
return jsonify({"error": "repo must be in the form owner/name"}), 400
try:
ttl = int(ttl_raw) if ttl_raw else _REPO_HASH_INTERVAL
except ValueError:
ttl = _REPO_HASH_INTERVAL
ttl = max(30, min(ttl, 3600))
force_refresh = refresh_flag in {'1', 'true', 'yes', 'force', 'refresh'}
if repo == _DEFAULT_REPO and branch == _DEFAULT_BRANCH:
result = _refresh_default_repo_hash(force=force_refresh)
else:
result = _fetch_repo_head(repo, branch, ttl_seconds=ttl, force_refresh=force_refresh)
sha = (result.get('sha') or '').strip()
payload = {
'repo': repo,
'branch': branch,
'sha': sha if sha else None,
'cached': bool(result.get('cached')),
'age_seconds': result.get('age_seconds'),
'source': result.get('source'),
}
if result.get('error'):
payload['error'] = result['error']
if sha:
return jsonify(payload)
return jsonify(payload), 503
except Exception as exc:
_write_service_log('server', f'/api/agent/repo_hash error: {exc}')
return jsonify({"error": "internal error"}), 500
# --------------------------------------------- # ---------------------------------------------
# Server Time Endpoint # Server Time Endpoint
# --------------------------------------------- # ---------------------------------------------