personal memory agent
0
fork

Configure Feed

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

init: converge /init/observers on the server freshness classifier

/init/observers now returns {thresholds, observers} with per-observer
state/group/label/elapsed_ms/clock_skew — identical shape to
/app/observer/api/list. init.html consumes server state, drops the
120000 client-side freshness literal, and picks a section-4 heading
from the observed state mix (all-connected / all-stale /
all-disconnected / mixed). Adds a shared _serialize_observer helper
in apps/observer/routes.py so both endpoints emit the same per-record
shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+171 -51
+23 -23
apps/observer/routes.py
··· 145 145 } 146 146 147 147 148 + def _serialize_observer(observer: dict[str, Any], current_now: int) -> dict[str, Any]: 149 + """Serialize a registered observer for management API consumers.""" 150 + freshness = _classify_observer_freshness( 151 + observer.get("last_seen"), 152 + observer.get("revoked", False), 153 + current_now, 154 + ) 155 + return { 156 + "key_prefix": observer.get("key", "")[:8], 157 + "name": observer.get("name", ""), 158 + "created_at": observer.get("created_at", 0), 159 + "last_seen": observer.get("last_seen"), 160 + "last_segment": observer.get("last_segment"), 161 + "enabled": observer.get("enabled", True), 162 + "revoked": observer.get("revoked", False), 163 + "revoked_at": observer.get("revoked_at"), 164 + "stats": observer.get("stats", {}), 165 + **freshness, 166 + "label": OBSERVER_STATE_LABELS[str(freshness["state"])], 167 + } 168 + 169 + 148 170 def _revoke_observer(key: str) -> bool: 149 171 """Revoke observer by key (soft-delete).""" 150 172 observer = load_observer(key) ··· 164 186 current_now = now_ms() 165 187 observers = list_observers() 166 188 # Sanitize output - don't expose full keys 167 - result = [] 168 - for r in observers: 169 - key_prefix = r.get("key", "")[:8] 170 - freshness = _classify_observer_freshness( 171 - r.get("last_seen"), 172 - r.get("revoked", False), 173 - current_now, 174 - ) 175 - result.append( 176 - { 177 - "key_prefix": key_prefix, 178 - "name": r.get("name", ""), 179 - "created_at": r.get("created_at", 0), 180 - "last_seen": r.get("last_seen"), 181 - "last_segment": r.get("last_segment"), 182 - "enabled": r.get("enabled", True), 183 - "revoked": r.get("revoked", False), 184 - "revoked_at": r.get("revoked_at"), 185 - "stats": r.get("stats", {}), 186 - **freshness, 187 - "label": OBSERVER_STATE_LABELS[str(freshness["state"])], 188 - } 189 - ) 189 + result = [_serialize_observer(observer, current_now) for observer in observers] 190 190 191 191 group_order = {"active": 0, "stale": 1, "inactive": 2} 192 192 result.sort(
+17 -14
convey/root.py
··· 194 194 195 195 @bp.route("/init/observers") 196 196 def init_observers() -> Any: 197 + from apps.observer.routes import ( 198 + ACTIVE_THRESHOLD_MS, 199 + STALE_THRESHOLD_MS, 200 + _serialize_observer, 201 + ) 197 202 from apps.observer.utils import list_observers 203 + from think.utils import now_ms 198 204 205 + current_now = now_ms() 199 206 observers_list = [] 200 207 for observer in list_observers(): 201 208 if observer.get("revoked", False): 202 209 continue 203 - observers_list.append( 204 - { 205 - "key_prefix": observer.get("key", "")[:8], 206 - "name": observer.get("name", ""), 207 - "created_at": observer.get("created_at", 0), 208 - "last_seen": observer.get("last_seen"), 209 - "last_segment": observer.get("last_segment"), 210 - "enabled": observer.get("enabled", True), 211 - "revoked": observer.get("revoked", False), 212 - "revoked_at": observer.get("revoked_at"), 213 - "stats": observer.get("stats", {}), 214 - } 215 - ) 216 - return jsonify(observers_list) 210 + observers_list.append(_serialize_observer(observer, current_now)) 211 + return jsonify( 212 + { 213 + "thresholds": { 214 + "active_ms": ACTIVE_THRESHOLD_MS, 215 + "stale_ms": STALE_THRESHOLD_MS, 216 + }, 217 + "observers": observers_list, 218 + } 219 + ) 217 220 218 221 219 222 @bp.route("/init/finalize", methods=["POST"])
+26 -11
convey/templates/init.html
··· 56 56 .observer-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.4rem 0; } 57 57 .observer-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; } 58 58 .observer-dot.connected { background: #22c55e; } 59 + .observer-dot.stale { background: #f59e0b; } 59 60 .observer-dot.disconnected { background: #d1d5db; } 60 61 .observer-state { font-size: 0.8rem; color: #999; margin-left: auto; } 61 62 .input-wrap { position: relative; } ··· 272 273 async function loadObservers() { 273 274 const emptyEl = document.getElementById('observer-empty'); 274 275 const errorEl = document.getElementById('observer-error'); 276 + const labelEl = document.getElementById('observer-label'); 277 + const listEl = document.getElementById('observer-list'); 275 278 try { 276 - const data = await window.apiJson('/init/observers'); 279 + const payload = await window.apiJson('/init/observers'); 277 280 if (errorEl) { 278 281 errorEl.hidden = true; 279 282 errorEl.textContent = ''; 280 283 } 281 - if (data.length > 0) { 284 + const observers = payload?.observers || []; 285 + if (observers.length > 0) { 282 286 if (emptyEl) emptyEl.style.display = 'none'; 283 - const labelEl = document.getElementById('observer-label'); 284 - if (labelEl) labelEl.style.display = 'block'; 285 - const now = Date.now(); 286 - document.getElementById('observer-list').innerHTML = data.map(r => { 287 - const connected = r.last_seen && (now - r.last_seen) < 120000; 288 - const label = esc(r.name || r.key_prefix); 287 + if (labelEl) { 288 + const states = new Set(observers.map(r => r.state)); 289 + const headingByState = { 290 + connected: 'all observers connected', 291 + stale: 'all observers stale', 292 + disconnected: 'all observers disconnected' 293 + }; 294 + const [state] = states; 295 + labelEl.textContent = states.size === 1 ? headingByState[state] || 'observers' : 'observers'; 296 + labelEl.style.display = 'block'; 297 + } 298 + listEl.innerHTML = observers.map(r => { 299 + const dotState = r.state === 'connected' || r.state === 'stale' ? r.state : 'disconnected'; 300 + const name = esc(r.name || r.key_prefix); 301 + const status = esc(r.label || r.state || 'disconnected'); 289 302 return '<div class="observer-item"><span class="observer-dot ' + 290 - (connected ? 'connected' : 'disconnected') + '"></span><span>' + 291 - label + '</span><span class="observer-state">' + 292 - (connected ? 'connected' : 'disconnected') + '</span></div>'; 303 + dotState + '"></span><span>' + 304 + name + '</span><span class="observer-state">' + 305 + status + '</span></div>'; 293 306 }).join(''); 294 307 } else { 295 308 if (emptyEl) emptyEl.style.display = 'block'; 309 + if (labelEl) labelEl.style.display = 'none'; 310 + listEl.innerHTML = ''; 296 311 } 297 312 } catch (err) { 298 313 if (emptyEl) emptyEl.style.display = 'none';
+105 -3
tests/test_init.py
··· 3 3 4 4 import pytest 5 5 6 + from apps.observer.routes import ACTIVE_THRESHOLD_MS, STALE_THRESHOLD_MS 7 + from apps.observer.utils import save_observer 6 8 from convey import create_app 9 + from think.utils import now_ms 7 10 8 11 9 12 def _read_config(journal_dir): ··· 19 22 (journal_dir / "config" / "journal.json").write_text(json.dumps(config, indent=2)) 20 23 21 24 25 + def _save_test_observer( 26 + key_prefix: str, 27 + name: str, 28 + *, 29 + created_at: int, 30 + last_seen: int | None, 31 + revoked: bool = False, 32 + ): 33 + key = key_prefix + ("f" * 56) 34 + assert save_observer( 35 + { 36 + "key": key, 37 + "name": name, 38 + "created_at": created_at, 39 + "last_seen": last_seen, 40 + "last_segment": None, 41 + "enabled": True, 42 + "revoked": revoked, 43 + "revoked_at": created_at + 1 if revoked else None, 44 + "stats": {}, 45 + } 46 + ) 47 + return key 48 + 49 + 22 50 @pytest.fixture 23 51 def fresh_client(journal_copy): 24 52 _remove_password(journal_copy) ··· 108 136 class TestInitObservers: 109 137 """Tests for the observer list endpoint during onboarding.""" 110 138 139 + def test_init_observers_returns_thresholds_and_observers_dict( 140 + self, fresh_client, monkeypatch 141 + ): 142 + monkeypatch.setattr( 143 + "apps.observer.utils.list_observers", 144 + lambda: [], 145 + ) 146 + resp = fresh_client.get("/init/observers") 147 + assert resp.status_code == 200 148 + data = resp.get_json() 149 + assert data == { 150 + "thresholds": { 151 + "active_ms": ACTIVE_THRESHOLD_MS, 152 + "stale_ms": STALE_THRESHOLD_MS, 153 + }, 154 + "observers": [], 155 + } 156 + assert isinstance(data["thresholds"]["active_ms"], int) 157 + assert isinstance(data["thresholds"]["stale_ms"], int) 158 + 111 159 def test_observers_no_password_required(self, fresh_client, monkeypatch): 112 160 """Observers endpoint works without password_hash set.""" 113 161 monkeypatch.setattr( ··· 148 196 resp = fresh_client.get("/init/observers") 149 197 assert resp.status_code == 200 150 198 data = resp.get_json() 151 - assert len(data) == 1 152 - assert data[0]["name"] == "my-phone" 153 - assert data[0]["key_prefix"] == "abcd1234" 199 + observers = data["observers"] 200 + assert len(observers) == 1 201 + assert observers[0]["name"] == "my-phone" 202 + assert observers[0]["key_prefix"] == "abcd1234" 203 + assert observers[0]["state"] == "disconnected" 204 + assert observers[0]["group"] == "inactive" 205 + assert observers[0]["label"] == "Disconnected" 206 + assert observers[0]["elapsed_ms"] is None 207 + assert observers[0]["clock_skew"] is False 208 + 209 + def test_init_observers_endpoint_parity(self, fresh_client): 210 + current_now = now_ms() 211 + _save_test_observer( 212 + "aaaa0000", 213 + "active-observer", 214 + created_at=10, 215 + last_seen=current_now - 5_000, 216 + ) 217 + _save_test_observer( 218 + "bbbb0000", 219 + "stale-observer", 220 + created_at=20, 221 + last_seen=current_now - 60_000, 222 + ) 223 + _save_test_observer( 224 + "cccc0000", 225 + "disconnected-observer", 226 + created_at=30, 227 + last_seen=current_now - 600_000, 228 + ) 229 + 230 + with fresh_client.session_transaction() as sess: 231 + sess["logged_in"] = True 232 + 233 + api_resp = fresh_client.get("/app/observer/api/list") 234 + init_resp = fresh_client.get("/init/observers") 235 + assert api_resp.status_code == 200 236 + assert init_resp.status_code == 200 237 + 238 + api_by_key = { 239 + observer["key_prefix"]: observer 240 + for observer in api_resp.get_json()["observers"] 241 + if not observer["revoked"] 242 + } 243 + init_by_key = { 244 + observer["key_prefix"]: observer 245 + for observer in init_resp.get_json()["observers"] 246 + } 247 + 248 + assert set(init_by_key) == set(api_by_key) 249 + for key_prefix, init_observer in init_by_key.items(): 250 + api_observer = api_by_key[key_prefix] 251 + assert init_observer["state"] == api_observer["state"] 252 + assert init_observer["group"] == api_observer["group"] 253 + assert init_observer["label"] == api_observer["label"] 254 + assert init_observer["clock_skew"] == api_observer["clock_skew"] 255 + assert abs(init_observer["elapsed_ms"] - api_observer["elapsed_ms"]) < 200 154 256 155 257 156 258 class TestInitFinalize: