personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge branch 'hopper-fsktnuwm-system-health-visibility'

+432
+4
convey/__init__.py
··· 18 18 from apps import AppRegistry 19 19 20 20 from . import state 21 + from . import system 21 22 from .apps import register_app_context 22 23 from .bridge import emit, register_websocket 23 24 from .config import bp as config_bp ··· 139 140 140 141 # Register triage API blueprint (universal chat bar) 141 142 app.register_blueprint(triage_bp) 143 + 144 + # Register system health API blueprint 145 + app.register_blueprint(system.bp) 142 146 143 147 # Initialize and register app system 144 148 registry = AppRegistry()
+170
convey/system.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """System health API endpoint.""" 5 + 6 + from __future__ import annotations 7 + 8 + import logging 9 + import time 10 + from typing import Any 11 + 12 + from flask import Blueprint, jsonify 13 + 14 + logger = logging.getLogger(__name__) 15 + 16 + bp = Blueprint("system", __name__, url_prefix="/api/system") 17 + 18 + # Freshness thresholds (ms) — matches apps/observer/workspace.html:744-749 19 + _CONNECTED_MS = 30_000 20 + _STALE_MS = 120_000 21 + 22 + # Version check cache TTL: 24 hours 23 + _VERSION_CACHE_TTL_S = 86400 24 + 25 + 26 + def collect_version() -> str | None: 27 + """Return the installed solstone version string.""" 28 + from apps.support.diagnostics import collect_version as _collect_version 29 + 30 + return _collect_version() 31 + 32 + 33 + def list_observers() -> list[dict]: 34 + """Return observer metadata records.""" 35 + from apps.observer.utils import list_observers as _list_observers 36 + 37 + return _list_observers() 38 + 39 + 40 + def _check_latest_version() -> dict[str, Any] | None: 41 + """Fetch latest release from GitHub. Returns None on any failure.""" 42 + import json as _json 43 + import urllib.request 44 + 45 + url = "https://api.github.com/repos/solpbc/solstone/releases/latest" 46 + req = urllib.request.Request( 47 + url, 48 + headers={"Accept": "application/vnd.github.v3+json"}, 49 + ) 50 + try: 51 + with urllib.request.urlopen(req, timeout=5) as resp: 52 + data = _json.loads(resp.read()) 53 + tag = data.get("tag_name", "") 54 + # Strip leading 'v' if present 55 + return {"latest": tag.lstrip("v")} 56 + except Exception: 57 + logger.debug("GitHub version check failed", exc_info=True) 58 + return None 59 + 60 + 61 + def _get_version_info() -> dict[str, Any]: 62 + """Get version info with cached GitHub check.""" 63 + from think.awareness import get_current, update_state 64 + 65 + current = collect_version() or "unknown" 66 + result: dict[str, Any] = {"current": current} 67 + 68 + # Check cache 69 + awareness = get_current() 70 + cached = awareness.get("version", {}) 71 + checked_at = cached.get("checked_at", 0) 72 + now = time.time() 73 + 74 + if now - checked_at < _VERSION_CACHE_TTL_S and "latest" in cached: 75 + result["latest"] = cached["latest"] 76 + else: 77 + # Fetch fresh 78 + fresh = _check_latest_version() 79 + if fresh: 80 + result["latest"] = fresh["latest"] 81 + update_state( 82 + "version", 83 + { 84 + "latest": fresh["latest"], 85 + "checked_at": now, 86 + }, 87 + ) 88 + elif "latest" in cached: 89 + # Use stale cache on failure 90 + result["latest"] = cached["latest"] 91 + 92 + if "latest" in result: 93 + result["update_available"] = result["latest"] != current 94 + 95 + return result 96 + 97 + 98 + def _get_capture_health() -> dict[str, Any]: 99 + """Derive capture health from observer last_seen timestamps.""" 100 + from think.utils import now_ms 101 + 102 + observers = list_observers() 103 + # Filter to active (non-revoked, enabled) observers 104 + active = [ 105 + o 106 + for o in observers 107 + if not o.get("revoked", False) and o.get("enabled", True) 108 + ] 109 + 110 + if not active: 111 + return { 112 + "status": "no_observers", 113 + "observers": [], 114 + } 115 + 116 + now = now_ms() 117 + observer_summaries = [] 118 + statuses = [] 119 + 120 + for o in active: 121 + last_seen = o.get("last_seen") 122 + if last_seen is None: 123 + obs_status = "offline" 124 + else: 125 + elapsed = now - last_seen 126 + if elapsed < _CONNECTED_MS: 127 + obs_status = "active" 128 + elif elapsed < _STALE_MS: 129 + obs_status = "stale" 130 + else: 131 + obs_status = "offline" 132 + 133 + statuses.append(obs_status) 134 + observer_summaries.append( 135 + { 136 + "name": o.get("name", "unknown"), 137 + "last_seen": last_seen, 138 + "status": obs_status, 139 + } 140 + ) 141 + 142 + # Overall status is best healthy state across observers. 143 + if "active" in statuses: 144 + overall = "active" 145 + elif "stale" in statuses: 146 + overall = "stale" 147 + else: 148 + overall = "offline" 149 + 150 + return { 151 + "status": overall, 152 + "observers": observer_summaries, 153 + } 154 + 155 + 156 + @bp.route("/status") 157 + def system_status(): 158 + """Return system health: version, capture status, overall ok.""" 159 + version = _get_version_info() 160 + capture = _get_capture_health() 161 + 162 + ok = capture["status"] in ("active", "no_observers") 163 + 164 + return jsonify( 165 + { 166 + "version": version, 167 + "capture": capture, 168 + "ok": ok, 169 + } 170 + )
+109
convey/templates/status_pane.html
··· 14 14 </div> 15 15 </details> 16 16 17 + <div id="capture-status-section" style="margin-top: 12px; display: none;"> 18 + <h4 style="margin: 0 0 4px 0; font-size: 13px; font-weight: 600;">capture</h4> 19 + <span id="capture-status-text" style="font-size: 13px;"></span> 20 + </div> 21 + 22 + <div id="version-section" style="margin-top: 12px; display: none;"> 23 + <h4 style="margin: 0 0 4px 0; font-size: 13px; font-weight: 600;">version</h4> 24 + <span id="version-text" style="font-size: 13px;"></span> 25 + </div> 26 + 17 27 <div id="quiet-notifs-section" style="display:none; margin-top: 16px;"> 18 28 <h3 style="margin-bottom: 8px;">quiet notifications</h3> 19 29 <div id="quiet-notifs-list" style="display: flex; flex-direction: column; gap: 4px; font-size: 13px;"></div> ··· 34 44 const liveRegion = document.getElementById('status-live-region'); 35 45 let statusPaneOpen = false; 36 46 let _lastHistoryLen = -1; 47 + let _systemStatusInterval = null; 48 + let _lastCaptureStatus = null; 37 49 38 50 // Shared label updater — called from here and from websocket.js 39 51 window.updateStatusLabel = function() { ··· 59 71 window.AppServices?.quietNotifs?.markViewed(); 60 72 renderQuietNotifs(); 61 73 updateStatusPane(); 74 + fetchSystemStatus(); 75 + _systemStatusInterval = setInterval(fetchSystemStatus, 60000); 62 76 } else { 63 77 statusPane.classList.remove('visible'); 78 + if (_systemStatusInterval) { clearInterval(_systemStatusInterval); _systemStatusInterval = null; } 64 79 statusIcon.focus(); 65 80 } 66 81 }); ··· 72 87 statusPaneOpen = false; 73 88 statusPane.classList.remove('visible'); 74 89 statusIcon.setAttribute('aria-expanded', 'false'); 90 + if (_systemStatusInterval) { clearInterval(_systemStatusInterval); _systemStatusInterval = null; } 75 91 window.updateStatusLabel(); 76 92 statusIcon.focus(); 77 93 } ··· 83 99 statusPaneOpen = false; 84 100 statusPane.classList.remove('visible'); 85 101 statusIcon.setAttribute('aria-expanded', 'false'); 102 + if (_systemStatusInterval) { clearInterval(_systemStatusInterval); _systemStatusInterval = null; } 86 103 window.updateStatusLabel(); 87 104 statusIcon.focus(); 88 105 } ··· 154 171 } 155 172 } 156 173 174 + // Update composite icon if we have capture data 175 + if (_lastCaptureStatus !== null) updateCompositeIcon(); 176 + 157 177 // Update notification history 158 178 updateNotificationHistory(); 159 179 updateBellState(); 180 + } 181 + 182 + function fetchSystemStatus() { 183 + fetch('/api/system/status') 184 + .then(r => r.ok ? r.json() : null) 185 + .catch(() => null) 186 + .then(data => { 187 + if (!data) return; 188 + _lastCaptureStatus = data.capture?.status || null; 189 + renderCaptureSection(data.capture); 190 + renderVersionSection(data.version); 191 + updateCompositeIcon(); 192 + }); 193 + } 194 + 195 + function renderCaptureSection(capture) { 196 + const section = document.getElementById('capture-status-section'); 197 + const text = document.getElementById('capture-status-text'); 198 + if (!section || !text) return; 199 + 200 + section.style.display = ''; 201 + const status = capture?.status; 202 + if (status === 'active') { 203 + text.textContent = 'capture active'; 204 + text.style.color = '#10b981'; 205 + } else if (status === 'stale') { 206 + const names = (capture.observers || []).filter(o => o.status === 'stale').map(o => o.name).join(', '); 207 + text.textContent = 'capture stale' + (names ? ' — ' + names : ''); 208 + text.style.color = '#f59e0b'; 209 + } else if (status === 'offline') { 210 + text.textContent = 'capture offline'; 211 + text.style.color = '#ef4444'; 212 + } else { 213 + text.textContent = 'no capture devices connected'; 214 + text.style.color = '#9ca3af'; 215 + } 216 + } 217 + 218 + function renderVersionSection(version) { 219 + const section = document.getElementById('version-section'); 220 + const text = document.getElementById('version-text'); 221 + if (!section || !text) return; 222 + 223 + section.style.display = ''; 224 + if (version?.update_available) { 225 + text.textContent = ''; 226 + text.appendChild(document.createTextNode('v' + (version.current || '?') + ' — ')); 227 + const span = document.createElement('span'); 228 + span.style.color = '#f59e0b'; 229 + span.textContent = 'update available (v' + (version.latest || '?') + ')'; 230 + text.appendChild(span); 231 + text.style.color = ''; 232 + } else { 233 + text.textContent = 'v' + (version?.current || 'unknown'); 234 + text.style.color = '#9ca3af'; 235 + } 236 + } 237 + 238 + function updateCompositeIcon() { 239 + const metrics = window.appEvents?.getMetrics?.(); 240 + const wsState = metrics?.state || 'disconnected'; 241 + 242 + // Map capture status to severity: active=0, no_observers=0, stale=1, offline=2 243 + // Map WS state to severity: connected=0, connecting=1, disconnected=2 244 + const captureSeverity = { active: 0, no_observers: 0, stale: 1, offline: 2 }; 245 + const wsSeverity = { connected: 0, connecting: 1, disconnected: 2 }; 246 + 247 + const cs = captureSeverity[_lastCaptureStatus] ?? 0; 248 + const ws = wsSeverity[wsState] ?? 2; 249 + 250 + // Only override icon if capture makes it worse than WS alone 251 + if (cs <= ws) return; 252 + 253 + const worst = Math.max(cs, ws); 254 + const iconState = worst === 0 ? 'connected' : worst === 1 ? 'connecting' : 'disconnected'; 255 + 256 + // Directly update the SVG without touching connectionState 257 + const icon = document.querySelector('.facet-bar .status-icon'); 258 + if (!icon) return; 259 + 260 + const svgs = { 261 + connected: '<svg class="status-indicator" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><circle cx="8" cy="8" r="6" fill="#10b981"/></svg>', 262 + connecting: '<svg class="status-indicator status-indicator--connecting" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><circle cx="8" cy="8" r="6" fill="none" stroke="#f59e0b" stroke-width="2.5" stroke-dasharray="24 8"/></svg>', 263 + disconnected: '<svg class="status-indicator" viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d="M8 2 L14 13 L2 13 Z" fill="#ef4444"/></svg>' 264 + }; 265 + 266 + const badge = icon.querySelector('#quiet-notif-badge'); 267 + icon.innerHTML = svgs[iconState] || svgs.disconnected; 268 + if (badge) icon.appendChild(badge); 160 269 } 161 270 162 271 function renderQuietNotifs() {
+149
tests/test_system_status.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for the /api/system/status endpoint.""" 5 + 6 + import json 7 + import sys 8 + import time 9 + from pathlib import Path 10 + from unittest.mock import patch 11 + 12 + import pytest 13 + 14 + ROOT = Path(__file__).resolve().parents[1] 15 + if str(ROOT) not in sys.path: 16 + sys.path.insert(0, str(ROOT)) 17 + 18 + import convey 19 + 20 + from convey import create_app 21 + 22 + system_mod = convey.system 23 + 24 + 25 + @pytest.fixture(autouse=True) 26 + def _temp_journal(monkeypatch, tmp_path): 27 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 28 + 29 + 30 + @pytest.fixture 31 + def client(tmp_path, monkeypatch): 32 + monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(tmp_path)) 33 + journal = tmp_path 34 + (journal / "config").mkdir(parents=True, exist_ok=True) 35 + config = { 36 + "convey": {"password_hash": "", "trust_localhost": True}, 37 + "setup": {"completed_at": 1}, 38 + } 39 + (journal / "config" / "journal.json").write_text(json.dumps(config)) 40 + app = create_app(str(journal)) 41 + return app.test_client() 42 + 43 + 44 + class TestSystemStatusEndpoint: 45 + def test_returns_valid_json_shape(self, client): 46 + with patch.object(system_mod, "list_observers", return_value=[]): 47 + resp = client.get("/api/system/status") 48 + assert resp.status_code == 200 49 + data = resp.get_json() 50 + assert "version" in data 51 + assert "capture" in data 52 + assert "ok" in data 53 + assert "current" in data["version"] 54 + assert "status" in data["capture"] 55 + assert "observers" in data["capture"] 56 + 57 + def test_no_observers(self, client): 58 + with patch.object(system_mod, "list_observers", return_value=[]): 59 + data = client.get("/api/system/status").get_json() 60 + assert data["capture"]["status"] == "no_observers" 61 + assert data["capture"]["observers"] == [] 62 + assert data["ok"] is True 63 + 64 + def test_active_observer(self, client): 65 + now = int(time.time() * 1000) 66 + observers = [{"name": "phone", "last_seen": now - 5000, "enabled": True}] 67 + with patch.object(system_mod, "list_observers", return_value=observers): 68 + data = client.get("/api/system/status").get_json() 69 + assert data["capture"]["status"] == "active" 70 + assert data["ok"] is True 71 + 72 + def test_stale_observer(self, client): 73 + now = int(time.time() * 1000) 74 + observers = [{"name": "phone", "last_seen": now - 60000, "enabled": True}] 75 + with patch.object(system_mod, "list_observers", return_value=observers): 76 + data = client.get("/api/system/status").get_json() 77 + assert data["capture"]["status"] == "stale" 78 + assert data["ok"] is False 79 + 80 + def test_offline_observer(self, client): 81 + now = int(time.time() * 1000) 82 + observers = [{"name": "phone", "last_seen": now - 300000, "enabled": True}] 83 + with patch.object(system_mod, "list_observers", return_value=observers): 84 + data = client.get("/api/system/status").get_json() 85 + assert data["capture"]["status"] == "offline" 86 + assert data["ok"] is False 87 + 88 + def test_revoked_observers_excluded(self, client): 89 + now = int(time.time() * 1000) 90 + observers = [ 91 + {"name": "phone", "last_seen": now - 5000, "enabled": True, "revoked": True}, 92 + ] 93 + with patch.object(system_mod, "list_observers", return_value=observers): 94 + data = client.get("/api/system/status").get_json() 95 + assert data["capture"]["status"] == "no_observers" 96 + 97 + def test_worst_of_multiple_observers(self, client): 98 + now = int(time.time() * 1000) 99 + observers = [ 100 + {"name": "phone", "last_seen": now - 5000, "enabled": True}, 101 + {"name": "laptop", "last_seen": now - 60000, "enabled": True}, 102 + ] 103 + with patch.object(system_mod, "list_observers", return_value=observers): 104 + data = client.get("/api/system/status").get_json() 105 + # At least one is active, so overall is active 106 + assert data["capture"]["status"] == "active" 107 + 108 + def test_version_github_failure_graceful(self, client): 109 + with ( 110 + patch.object(system_mod, "list_observers", return_value=[]), 111 + patch.object(system_mod, "_check_latest_version", return_value=None), 112 + ): 113 + data = client.get("/api/system/status").get_json() 114 + assert "current" in data["version"] 115 + # No "latest" or "update_available" when GitHub fails and no cache 116 + assert ( 117 + data["version"].get("update_available") is None 118 + or "latest" not in data["version"] 119 + ) 120 + 121 + def test_version_with_update_available(self, client): 122 + with ( 123 + patch.object(system_mod, "list_observers", return_value=[]), 124 + patch.object(system_mod, "_check_latest_version", return_value={"latest": "99.0.0"}), 125 + patch.object(system_mod, "collect_version", return_value="0.1.0"), 126 + ): 127 + data = client.get("/api/system/status").get_json() 128 + assert data["version"]["current"] == "0.1.0" 129 + assert data["version"]["latest"] == "99.0.0" 130 + assert data["version"]["update_available"] is True 131 + 132 + 133 + class TestCaptureHealthDerivation: 134 + """Unit tests for _get_capture_health logic.""" 135 + 136 + def test_no_last_seen_is_offline(self): 137 + with patch.object(system_mod, "list_observers", return_value=[{"name": "x", "enabled": True}]): 138 + result = system_mod._get_capture_health() 139 + assert result["observers"][0]["status"] == "offline" 140 + 141 + def test_disabled_observers_excluded(self): 142 + now = int(time.time() * 1000) 143 + with patch.object( 144 + system_mod, 145 + "list_observers", 146 + return_value=[{"name": "x", "last_seen": now, "enabled": False}], 147 + ): 148 + result = system_mod._get_capture_health() 149 + assert result["status"] == "no_observers"