personal memory agent
0
fork

Configure Feed

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

refactor: unify capture-health on a single live source

Move capture-health derivation into `think/capture_health.py` as a pure
read-time pull from the observer registry. `/api/system/status` and the
home Pulse page now share this one function, and the thresholds live in
exactly one place. Exception-safe: falls back to "unknown" rather than
propagating observer-side errors.

Remove the `awareness.capture` cache key and the P1 "Capture may be
offline" chat-bar banner / triage injection — a frozen-cache read was the
only thing keeping it alive. The awareness-tender and triage/chat talents
no longer emit or advise on a capture section.

Home-page pulse vitals now use the live status vocabulary directly
(`active`/`stale`/`offline`/`no_observers`/`unknown`), and the dot-class
CSS renames `.ok` to `.active` to match. `no_observers` renders as
"no observers" in display text.

+231 -223
+3 -3
apps/home/routes.py
··· 20 20 from convey.apps import _resolve_attention 21 21 from convey.bridge import get_cached_state 22 22 from think.awareness import get_current 23 + from think.capture_health import get_capture_health 23 24 from think.facets import get_enabled_facets, get_facets 24 25 from think.indexer.journal import get_journal_index 25 26 from think.pipeline_health import pipeline_status_message, summarize_pipeline_day ··· 607 608 today = _today() 608 609 now = datetime.now() 609 610 610 - awareness = get_current() 611 - capture_status = awareness.get("capture", {}).get("status", "unknown") 611 + capture_status = get_capture_health()["status"] 612 612 cached = get_cached_state() 613 613 last_observe_ts = cached.get("last_observe_ts") 614 - attention = _resolve_attention(awareness) 614 + attention = _resolve_attention(get_current()) 615 615 616 616 stats_data = _load_stats(today) 617 617 stats = stats_data.get("stats", {})
+6 -5
apps/home/workspace.html
··· 46 46 top: 0px; 47 47 } 48 48 49 - .pulse-vitals-dot.ok { background: #4ade80; } 49 + .pulse-vitals-dot.active { background: #4ade80; } 50 50 .pulse-vitals-dot.stale { background: #fbbf24; } 51 51 .pulse-vitals-dot.offline { background: #f87171; } 52 52 .pulse-vitals-dot.unknown { background: #9ca3af; } ··· 922 922 923 923 <!-- Vital Signs --> 924 924 <div class="pulse-vitals" id="pulse-vitals" role="status" aria-live="polite"> 925 - {% set dot_class = 'ok' if capture_status == 'ok' else ('stale' if capture_status == 'stale' else ('offline' if capture_status == 'offline' else 'unknown')) %} 925 + {% set dot_class = 'active' if capture_status == 'active' else ('stale' if capture_status == 'stale' else ('offline' if capture_status == 'offline' else 'unknown')) %} 926 926 <div class="pulse-vitals-item"> 927 927 <span class="pulse-vitals-dot {{ dot_class }}" aria-hidden="true"></span> 928 - <span>capture {{ capture_status }}</span> 928 + <span>capture {{ 'no observers' if capture_status == 'no_observers' else capture_status }}</span> 929 929 </div> 930 930 {% if segment_count > 0 %} 931 931 <div class="pulse-vitals-sep"></div> ··· 1436 1436 .then(data => { 1437 1437 const el = document.getElementById('pulse-vitals'); 1438 1438 if (!el) return; 1439 - let dotClass = data.capture_status === 'ok' ? 'ok' : (data.capture_status === 'stale' ? 'stale' : (data.capture_status === 'offline' ? 'offline' : 'unknown')); 1440 - let html = '<div class="pulse-vitals-item"><span class="pulse-vitals-dot ' + dotClass + '" aria-hidden="true"></span><span>capture ' + esc(data.capture_status) + '</span></div>'; 1439 + let dotClass = data.capture_status === 'active' ? 'active' : (data.capture_status === 'stale' ? 'stale' : (data.capture_status === 'offline' ? 'offline' : 'unknown')); 1440 + let displayStatus = data.capture_status === 'no_observers' ? 'no observers' : data.capture_status; 1441 + let html = '<div class="pulse-vitals-item"><span class="pulse-vitals-dot ' + dotClass + '" aria-hidden="true"></span><span>capture ' + esc(displayStatus) + '</span></div>'; 1441 1442 if (data.segment_count > 0) { 1442 1443 html += '<div class="pulse-vitals-sep"></div><div class="pulse-vitals-item">' + data.segment_count + ' segment' + (data.segment_count !== 1 ? 's' : ''); 1443 1444 if (data.duration_minutes > 0) html += ' · ' + data.duration_minutes + 'm';
+4 -14
convey/apps.py
··· 84 84 85 85 86 86 def _resolve_attention(awareness_current: dict) -> AttentionItem | None: 87 - """Check attention sources P0-P4, return highest priority or None.""" 87 + """Check attention sources P0-P3, return highest priority or None.""" 88 88 # P0: Cortex errors 89 89 try: 90 90 import json ··· 139 139 except Exception: 140 140 pass 141 141 142 - # P1: Capture stale 143 - capture = awareness_current.get("capture", {}) 144 - if capture.get("status") == "stale": 145 - placeholder = "Capture may be offline — ask me to check" 146 - context = [ 147 - "System health: capture appears offline (observer heartbeats stale). " 148 - "If user asks what needs attention, mention capture status." 149 - ] 150 - return AttentionItem(placeholder_text=placeholder, context_lines=context) 151 - 152 - # P2: Recent import completion 142 + # P1: Recent import completion 153 143 imports = awareness_current.get("imports", {}) 154 144 last_completed = imports.get("last_completed") 155 145 last_summary = imports.get("last_result_summary") ··· 173 163 except Exception: 174 164 pass 175 165 176 - # P3: Daily analysis highlights 166 + # P2: Daily analysis highlights 177 167 journal_state = awareness_current.get("journal", {}) 178 168 if journal_state.get("first_daily_ready"): 179 169 try: ··· 205 195 except Exception: 206 196 pass 207 197 208 - # P4: Owner voiceprint candidate ready for confirmation 198 + # P3: Owner voiceprint candidate ready for confirmation 209 199 voiceprint = awareness_current.get("voiceprint", {}) 210 200 if voiceprint.get("status") == "candidate": 211 201 cluster_size = voiceprint.get("cluster_size", 0)
+3 -68
convey/system.py
··· 11 11 12 12 from flask import Blueprint, jsonify 13 13 14 + from think.capture_health import get_capture_health 15 + 14 16 logger = logging.getLogger(__name__) 15 17 16 18 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 19 22 20 # Version check cache TTL: 24 hours 23 21 _VERSION_CACHE_TTL_S = 86400 ··· 28 26 from apps.support.diagnostics import collect_version as _collect_version 29 27 30 28 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 29 39 30 40 31 def _check_latest_version() -> dict[str, Any] | None: ··· 95 86 return result 96 87 97 88 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 for o in observers if not o.get("revoked", False) and o.get("enabled", True) 106 - ] 107 - 108 - if not active: 109 - return { 110 - "status": "no_observers", 111 - "observers": [], 112 - } 113 - 114 - now = now_ms() 115 - observer_summaries = [] 116 - statuses = [] 117 - 118 - for o in active: 119 - last_seen = o.get("last_seen") 120 - if last_seen is None: 121 - obs_status = "offline" 122 - else: 123 - elapsed = now - last_seen 124 - if elapsed < _CONNECTED_MS: 125 - obs_status = "active" 126 - elif elapsed < _STALE_MS: 127 - obs_status = "stale" 128 - else: 129 - obs_status = "offline" 130 - 131 - statuses.append(obs_status) 132 - observer_summaries.append( 133 - { 134 - "name": o.get("name", "unknown"), 135 - "last_seen": last_seen, 136 - "status": obs_status, 137 - } 138 - ) 139 - 140 - # Overall status is best healthy state across observers. 141 - if "active" in statuses: 142 - overall = "active" 143 - elif "stale" in statuses: 144 - overall = "stale" 145 - else: 146 - overall = "offline" 147 - 148 - return { 149 - "status": overall, 150 - "observers": observer_summaries, 151 - } 152 - 153 - 154 89 @bp.route("/status") 155 90 def system_status(): 156 91 """Return system health: version, capture status, overall ok.""" 157 92 version = _get_version_info() 158 - capture = _get_capture_health() 93 + capture = get_capture_health() 159 94 160 95 ok = capture["status"] in ("active", "no_observers") 161 96
+4 -4
convey/templates/status_pane.html
··· 199 199 200 200 section.style.display = ''; 201 201 const status = capture?.status; 202 + const displayStatus = status === 'no_observers' ? 'no observers' : (status || 'unknown'); 202 203 if (status === 'active') { 203 - text.textContent = 'capture active'; 204 204 text.style.color = '#10b981'; 205 205 } else if (status === 'stale') { 206 206 const names = (capture.observers || []).filter(o => o.status === 'stale').map(o => o.name).join(', '); 207 - text.textContent = 'capture stale' + (names ? ' — ' + names : ''); 207 + text.textContent = 'capture ' + displayStatus + (names ? ' — ' + names : ''); 208 208 text.style.color = '#f59e0b'; 209 + return; 209 210 } else if (status === 'offline') { 210 - text.textContent = 'capture offline'; 211 211 text.style.color = '#ef4444'; 212 212 } else { 213 - text.textContent = 'no capture devices connected'; 214 213 text.style.color = '#9ca3af'; 215 214 } 215 + text.textContent = 'capture ' + displayStatus; 216 216 } 217 217 218 218 function renderVersionSection(version) {
+1 -5
talent/awareness_tender.md
··· 19 19 20 20 Read current state using these tools: 21 21 22 - 1. `sol call awareness status` — capture, processing, import, and journal state 22 + 1. `sol call awareness status` — processing, import, and journal state 23 23 2. `sol call identity self` — identity summary (skim for key changes) 24 24 3. `sol call calendar list` — today's events 25 25 4. `sol call routines list` — active routines and recent outputs ··· 31 31 ``` 32 32 as of: {ISO 8601 datetime} 33 33 segment: {$SOL_SEGMENT} 34 - 35 - ## capture 36 - - status: {active|stale|offline} 37 - - streams: {list of active streams} 38 34 39 35 ## calendar 40 36 - {key events for today, 1-3 bullets}
-1
talent/chat.md
··· 305 305 306 306 - **"what needs my attention?"** — Report the system health item. Be concise. 307 307 - **Agent errors:** Explain which agents failed. Suggest checking logs. 308 - - **Capture offline:** Suggest checking that the observer service is running. 309 308 - **Import complete:** Describe what was imported, offer to explore or import more. 310 309 311 310 When no `System health:` line is present, everything is fine.
+2 -3
talent/triage.md
··· 40 40 - `sol call journal events [DAY] [-f FACET]` — List events with participants, times, and summaries. 41 41 42 42 ### Awareness 43 - - `sol call awareness status [SECTION]` — Read awareness state (e.g., capture state, journal health). 43 + - `sol call awareness status [SECTION]` — Read awareness state (e.g., processing state, journal health). 44 44 - `sol call awareness log-read [DAY] [--kind KIND] [--limit N]` — Read awareness log entries. 45 45 46 46 ### Support ··· 66 66 67 67 When the context includes a `System health:` line, there is an active attention item. Handle these queries: 68 68 69 - - **"what needs my attention?"** — Report the system health item from context. If there are agent errors, mention which agents failed. If capture is stale, mention it may be offline. If an import just completed, mention what arrived. Be concise. 69 + - **"what needs my attention?"** — Report the system health item from context. If there are agent errors, mention which agents failed. If an import just completed, mention what arrived. Be concise. 70 70 - **Agent errors**: If the owner asks about errors, explain which agents failed today. Suggest checking agent logs or re-running the daily analysis. 71 - - **Capture offline**: If capture appears stale, suggest checking that the observer service is running. 72 71 - **Import complete**: If an import just finished, briefly describe what was imported and offer to explore the new data or import from another source. 73 72 74 73 When no `System health:` line is present in context, there is nothing to report. If the owner asks "what needs my attention?", respond that everything looks good.
+46
tests/test_capture_health.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for live capture-health derivation.""" 5 + 6 + import sys 7 + from pathlib import Path 8 + 9 + ROOT = Path(__file__).resolve().parents[1] 10 + if str(ROOT) not in sys.path: 11 + sys.path.insert(0, str(ROOT)) 12 + 13 + from think.capture_health import get_capture_health 14 + 15 + 16 + def test_no_last_seen_is_offline(monkeypatch): 17 + monkeypatch.setattr( 18 + "apps.observer.utils.list_observers", 19 + lambda: [{"name": "x", "enabled": True}], 20 + ) 21 + 22 + result = get_capture_health() 23 + 24 + assert result["observers"][0]["status"] == "offline" 25 + 26 + 27 + def test_disabled_observers_excluded(monkeypatch): 28 + monkeypatch.setattr( 29 + "apps.observer.utils.list_observers", 30 + lambda: [{"name": "x", "last_seen": 1000, "enabled": False}], 31 + ) 32 + 33 + result = get_capture_health() 34 + 35 + assert result["status"] == "no_observers" 36 + 37 + 38 + def test_list_observers_raises_returns_unknown(monkeypatch): 39 + def _raise() -> list[dict]: 40 + raise RuntimeError("boom") 41 + 42 + monkeypatch.setattr("apps.observer.utils.list_observers", _raise) 43 + 44 + result = get_capture_health() 45 + 46 + assert result == {"status": "unknown", "observers": []}
+2 -3
tests/test_chat_context.py
··· 5 5 6 6 from talent.chat_context import pre_process 7 7 8 - 9 8 TEMPLATE_VAR_KEYS = { 10 9 "recent_conversation", 11 10 "active_routines", ··· 117 116 sol_dir = tmp_path / "sol" 118 117 sol_dir.mkdir() 119 118 (sol_dir / "awareness.md").write_text( 120 - "as of: 2026-04-05T10:00:00\n\n## capture\n- status: active\n" 119 + "as of: 2026-04-05T10:00:00\n\n## activity\n- focused on work\n" 121 120 ) 122 121 123 122 result = pre_process({"user_instruction": "Base instruction."}) 124 123 125 124 template_vars = _assert_template_vars_result(result) 126 125 assert "## Awareness" in template_vars["sol_awareness"] 127 - assert "capture" in template_vars["sol_awareness"] 126 + assert "activity" in template_vars["sol_awareness"] 128 127 129 128 130 129 def test_chat_context_sol_awareness_cold_start(monkeypatch, tmp_path):
+5 -67
tests/test_convey_apps.py
··· 98 98 current = {"imports": {"has_imported": True}, "journal": {}} 99 99 assert _resolve_attention(current) is None 100 100 101 - def test_p1_capture_stale(self): 102 - from convey.apps import _resolve_attention 103 - 104 - current = {"capture": {"status": "stale", "last_seen": 1000.0}} 105 - result = _resolve_attention(current) 106 - assert result is not None 107 - assert ( 108 - "offline" in result.placeholder_text.lower() 109 - or "stale" in result.placeholder_text.lower() 110 - ) 111 - assert len(result.placeholder_text) <= 90 112 - assert any("capture" in line.lower() for line in result.context_lines) 113 - 114 - def test_p1_capture_ok_no_attention(self): 115 - from convey.apps import _resolve_attention 116 - 117 - current = {"capture": {"status": "ok", "last_seen": 1000.0}} 118 - assert _resolve_attention(current) is None 119 - 120 - def test_p2_recent_import(self): 101 + def test_p1_recent_import(self): 121 102 from datetime import datetime 122 103 123 104 from convey.apps import _resolve_attention ··· 230 211 result = _resolve_attention({}) 231 212 assert result is None 232 213 233 - def test_priority_p0_over_p1(self, tmp_path, monkeypatch): 234 - """P0 (cortex errors) takes priority over P1 (capture stale).""" 214 + def test_priority_p0_over_p1_imports(self, tmp_path, monkeypatch): 215 + """P0 (cortex errors) takes priority over P1 (recent import).""" 235 216 import json 236 217 from datetime import datetime 237 218 ··· 255 236 ) 256 237 + "\n" 257 238 ) 258 - 259 - current = {"capture": {"status": "stale", "last_seen": 1000.0}} 260 - result = _resolve_attention(current) 261 - assert result is not None 262 - assert "error" in result.placeholder_text.lower() 263 - 264 - def test_priority_p1_over_p2(self): 265 - """P1 (capture stale) takes priority over P2 (recent import).""" 266 - from datetime import datetime 267 - 268 - from convey.apps import _resolve_attention 269 239 270 240 current = { 271 - "capture": {"status": "stale"}, 272 241 "imports": { 273 242 "has_imported": True, 274 243 "last_completed": datetime.now().isoformat(), 275 244 "last_result_summary": "10 items", 276 - }, 245 + } 277 246 } 278 247 result = _resolve_attention(current) 279 248 assert result is not None 280 - assert ( 281 - "offline" in result.placeholder_text.lower() 282 - or "capture" in result.placeholder_text.lower() 283 - ) 284 - 285 - def test_placeholder_with_attention_overrides_daily(self, tmp_path, monkeypatch): 286 - """Attention items override regular daily analysis placeholders.""" 287 - from convey.apps import _resolve_placeholder 288 - 289 - current = { 290 - "capture": {"status": "stale"}, 291 - "journal": {"first_daily_ready": True}, 292 - } 293 - result = _resolve_placeholder(current, 10) 294 - assert "offline" in result.lower() or "capture" in result.lower() 249 + assert "error" in result.placeholder_text.lower() 295 250 296 251 def test_placeholder_no_attention_preserves_behavior(self): 297 252 """When no attention items, existing placeholder logic unchanged.""" ··· 324 279 325 280 day_index.unlink() 326 281 agents_dir.rmdir() 327 - result = _resolve_attention({"capture": {"status": "stale"}}) 328 - assert result is not None 329 - assert len(result.placeholder_text) <= 90 330 - 331 282 result = _resolve_attention( 332 283 { 333 284 "imports": { ··· 363 314 364 315 class TestTriageSystemHealth: 365 316 """Tests for system health context injection in triage.""" 366 - 367 - def test_triage_injects_health_context_when_capture_stale( 368 - self, tmp_path, monkeypatch 369 - ): 370 - """System health context is added when attention items exist.""" 371 - from think.awareness import update_state 372 - 373 - update_state("capture", {"status": "stale", "last_seen": 1000.0}) 374 - 375 - mock = _run_triage() 376 - prompt = mock.call_args.kwargs["prompt"] 377 - assert "System health" in prompt 378 - assert "capture" in prompt.lower() 379 317 380 318 def test_triage_no_health_context_when_healthy(self): 381 319 """No system health context when nothing needs attention."""
+2 -1
tests/test_home_routines.py
··· 190 190 def test_api_pulse_includes_routines(monkeypatch, home_client): 191 191 """Pulse API includes the routines payload from the context builder.""" 192 192 monkeypatch.setattr( 193 - "apps.home.routes.get_current", lambda: {"capture": {"status": "ok"}} 193 + "apps.home.routes.get_capture_health", 194 + lambda: {"status": "active", "observers": []}, 194 195 ) 195 196 monkeypatch.setattr("apps.home.routes.get_cached_state", lambda: {}) 196 197 monkeypatch.setattr("apps.home.routes._resolve_attention", lambda awareness: None)
+2 -1
tests/test_home_skills.py
··· 203 203 def test_api_pulse_includes_skills(monkeypatch, home_client): 204 204 """Pulse API includes skills data from the context builder.""" 205 205 monkeypatch.setattr( 206 - "apps.home.routes.get_current", lambda: {"capture": {"status": "ok"}} 206 + "apps.home.routes.get_capture_health", 207 + lambda: {"status": "active", "observers": []}, 207 208 ) 208 209 monkeypatch.setattr("apps.home.routes.get_cached_state", lambda: {}) 209 210 monkeypatch.setattr("apps.home.routes._resolve_attention", lambda awareness: None)
+68 -48
tests/test_system_status.py
··· 5 5 6 6 import json 7 7 import sys 8 - import time 9 8 from pathlib import Path 10 9 from unittest.mock import patch 11 10 ··· 16 15 sys.path.insert(0, str(ROOT)) 17 16 18 17 import convey 19 - 20 18 from convey import create_app 21 19 22 20 system_mod = convey.system ··· 43 41 44 42 class TestSystemStatusEndpoint: 45 43 def test_returns_valid_json_shape(self, client): 46 - with patch.object(system_mod, "list_observers", return_value=[]): 44 + with patch.object( 45 + system_mod, 46 + "get_capture_health", 47 + return_value={"status": "no_observers", "observers": []}, 48 + ): 47 49 resp = client.get("/api/system/status") 48 50 assert resp.status_code == 200 49 51 data = resp.get_json() ··· 55 57 assert "observers" in data["capture"] 56 58 57 59 def test_no_observers(self, client): 58 - with patch.object(system_mod, "list_observers", return_value=[]): 60 + with patch.object( 61 + system_mod, 62 + "get_capture_health", 63 + return_value={"status": "no_observers", "observers": []}, 64 + ): 59 65 data = client.get("/api/system/status").get_json() 60 66 assert data["capture"]["status"] == "no_observers" 61 67 assert data["capture"]["observers"] == [] 62 68 assert data["ok"] is True 63 69 64 70 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): 71 + with patch.object( 72 + system_mod, 73 + "get_capture_health", 74 + return_value={ 75 + "status": "active", 76 + "observers": [{"name": "phone", "last_seen": 1000, "status": "active"}], 77 + }, 78 + ): 68 79 data = client.get("/api/system/status").get_json() 69 80 assert data["capture"]["status"] == "active" 70 81 assert data["ok"] is True 71 82 72 83 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): 84 + with patch.object( 85 + system_mod, 86 + "get_capture_health", 87 + return_value={ 88 + "status": "stale", 89 + "observers": [{"name": "phone", "last_seen": 1000, "status": "stale"}], 90 + }, 91 + ): 76 92 data = client.get("/api/system/status").get_json() 77 93 assert data["capture"]["status"] == "stale" 78 94 assert data["ok"] is False 79 95 80 96 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): 97 + with patch.object( 98 + system_mod, 99 + "get_capture_health", 100 + return_value={ 101 + "status": "offline", 102 + "observers": [ 103 + {"name": "phone", "last_seen": 1000, "status": "offline"} 104 + ], 105 + }, 106 + ): 84 107 data = client.get("/api/system/status").get_json() 85 108 assert data["capture"]["status"] == "offline" 86 109 assert data["ok"] is False 87 110 88 111 def test_revoked_observers_excluded(self, client): 89 - now = int(time.time() * 1000) 90 - observers = [ 91 - { 92 - "name": "phone", 93 - "last_seen": now - 5000, 94 - "enabled": True, 95 - "revoked": True, 96 - }, 97 - ] 98 - with patch.object(system_mod, "list_observers", return_value=observers): 112 + with patch.object( 113 + system_mod, 114 + "get_capture_health", 115 + return_value={"status": "no_observers", "observers": []}, 116 + ): 99 117 data = client.get("/api/system/status").get_json() 100 118 assert data["capture"]["status"] == "no_observers" 101 119 102 120 def test_worst_of_multiple_observers(self, client): 103 - now = int(time.time() * 1000) 104 - observers = [ 105 - {"name": "phone", "last_seen": now - 5000, "enabled": True}, 106 - {"name": "laptop", "last_seen": now - 60000, "enabled": True}, 107 - ] 108 - with patch.object(system_mod, "list_observers", return_value=observers): 121 + with patch.object( 122 + system_mod, 123 + "get_capture_health", 124 + return_value={ 125 + "status": "active", 126 + "observers": [ 127 + {"name": "phone", "last_seen": 1000, "status": "active"}, 128 + {"name": "laptop", "last_seen": 500, "status": "stale"}, 129 + ], 130 + }, 131 + ): 109 132 data = client.get("/api/system/status").get_json() 110 133 # At least one is active, so overall is active 111 134 assert data["capture"]["status"] == "active" 112 135 113 136 def test_version_github_failure_graceful(self, client): 114 137 with ( 115 - patch.object(system_mod, "list_observers", return_value=[]), 138 + patch.object( 139 + system_mod, 140 + "get_capture_health", 141 + return_value={"status": "no_observers", "observers": []}, 142 + ), 116 143 patch.object(system_mod, "_check_latest_version", return_value=None), 117 144 ): 118 145 data = client.get("/api/system/status").get_json() ··· 125 152 126 153 def test_version_with_update_available(self, client): 127 154 with ( 128 - patch.object(system_mod, "list_observers", return_value=[]), 155 + patch.object( 156 + system_mod, 157 + "get_capture_health", 158 + return_value={"status": "no_observers", "observers": []}, 159 + ), 129 160 patch.object( 130 161 system_mod, "_check_latest_version", return_value={"latest": "99.0.0"} 131 162 ), ··· 136 167 assert data["version"]["latest"] == "99.0.0" 137 168 assert data["version"]["update_available"] is True 138 169 139 - 140 - class TestCaptureHealthDerivation: 141 - """Unit tests for _get_capture_health logic.""" 142 - 143 - def test_no_last_seen_is_offline(self): 144 - with patch.object( 145 - system_mod, "list_observers", return_value=[{"name": "x", "enabled": True}] 146 - ): 147 - result = system_mod._get_capture_health() 148 - assert result["observers"][0]["status"] == "offline" 149 - 150 - def test_disabled_observers_excluded(self): 151 - now = int(time.time() * 1000) 170 + def test_unknown_status_is_not_ok(self, client): 152 171 with patch.object( 153 172 system_mod, 154 - "list_observers", 155 - return_value=[{"name": "x", "last_seen": now, "enabled": False}], 173 + "get_capture_health", 174 + return_value={"status": "unknown", "observers": []}, 156 175 ): 157 - result = system_mod._get_capture_health() 158 - assert result["status"] == "no_observers" 176 + data = client.get("/api/system/status").get_json() 177 + assert data["capture"]["status"] == "unknown" 178 + assert data["ok"] is False
+83
think/capture_health.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Live capture-health derivation. 5 + 6 + Read-time pull from `apps.observer.utils.list_observers()`. No cache, no 7 + write path -- every call returns fresh state. On any exception below the 8 + observer layer, returns ``{"status": "unknown", "observers": []}`` rather 9 + than propagating; callers render a neutral UI instead of crashing. 10 + """ 11 + 12 + from __future__ import annotations 13 + 14 + from think.utils import now_ms 15 + 16 + _CONNECTED_MS = 30_000 17 + _STALE_MS = 120_000 18 + 19 + 20 + def get_capture_health() -> dict: 21 + """Return {"status": ..., "observers": [...]}. 22 + 23 + status ∈ {"active", "stale", "offline", "no_observers", "unknown"}. 24 + Overall rollup: active if any observer is active, else stale if any is 25 + stale, else offline. "no_observers" when the filtered list is empty. 26 + """ 27 + from apps.observer.utils import list_observers 28 + 29 + try: 30 + observers = list_observers() 31 + # Filter to active (non-revoked, enabled) observers 32 + active = [ 33 + o 34 + for o in observers 35 + if not o.get("revoked", False) and o.get("enabled", True) 36 + ] 37 + 38 + if not active: 39 + return { 40 + "status": "no_observers", 41 + "observers": [], 42 + } 43 + 44 + now = now_ms() 45 + observer_summaries = [] 46 + statuses = [] 47 + 48 + for o in active: 49 + last_seen = o.get("last_seen") 50 + if last_seen is None: 51 + obs_status = "offline" 52 + else: 53 + elapsed = now - last_seen 54 + if elapsed < _CONNECTED_MS: 55 + obs_status = "active" 56 + elif elapsed < _STALE_MS: 57 + obs_status = "stale" 58 + else: 59 + obs_status = "offline" 60 + 61 + statuses.append(obs_status) 62 + observer_summaries.append( 63 + { 64 + "name": o.get("name", "unknown"), 65 + "last_seen": last_seen, 66 + "status": obs_status, 67 + } 68 + ) 69 + 70 + # Overall status is best healthy state across observers. 71 + if "active" in statuses: 72 + overall = "active" 73 + elif "stale" in statuses: 74 + overall = "stale" 75 + else: 76 + overall = "offline" 77 + 78 + return { 79 + "status": overall, 80 + "observers": observer_summaries, 81 + } 82 + except Exception: 83 + return {"status": "unknown", "observers": []}