personal memory agent
0
fork

Configure Feed

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

Rename remote app to observer across codebase

Directory renames: apps/remote -> apps/observer, observe/remote_cli.py -> observe/observer_cli.py, observe/remote_client.py -> observe/observer_client.py

All API routes, blueprints, CLI commands, utility functions, config keys, env vars, callosum event fields, JSONL metadata keys, tests, and docs updated.

Migration maint task added at apps/observer/maint/000_migrate_remote_to_observer.py.

No behavioral changes - pure rename with clean break (no backward compat aliases).

+1149 -1025
+1 -1
README.md
··· 59 59 +-------------+ 60 60 ``` 61 61 62 - - **observe** — receives captured audio and screen activity from standalone observers (solstone-linux, solstone-tmux, solstone-macos) via remote ingest. processes FLAC audio, WebM screen recordings, and timestamped metadata. 62 + - **observe** — receives captured audio and screen activity from standalone observers (solstone-linux, solstone-tmux, solstone-macos) vian observer ingest. processes FLAC audio, WebM screen recordings, and timestamped metadata. 63 63 - **think** — transcribes audio (faster-whisper), analyzes screen captures, extracts entities, detects meetings, and indexes everything into SQLite. runs 30 configurable agent/generator templates from `talent/`. 64 64 - **cortex** — orchestrates agent execution. receives events, dispatches agents, writes results back to the journal. 65 65 - **callosum** — async message bus connecting all services. enables event-driven coordination between observe, think, cortex, and convey.
+1 -1
apps/health/workspace.html
··· 820 820 </div> 821 821 </div> 822 822 <div class="observe-empty" id="observeEmpty"> 823 - No observers connected — <a href="/app/remote/">Manage observers →</a> 823 + No observers connected — <a href="/app/observer/">Manage observers →</a> 824 824 </div> 825 825 <div class="observe-content hidden" id="observeContent"> 826 826 <div class="observe-section">
+59
apps/observer/events.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Observer app event handlers for observer segment processing state.""" 5 + 6 + import logging 7 + 8 + from apps.events import EventContext, on_event 9 + from think.utils import now_ms 10 + 11 + from .utils import append_history_record, find_observer_by_name, increment_stat 12 + 13 + logger = logging.getLogger(__name__) 14 + 15 + 16 + @on_event("observe", "observed") 17 + def handle_observed(ctx: EventContext) -> None: 18 + """Track observe.observed events for observer-originated segments. 19 + 20 + When a segment from an observer completes processing, append 21 + an 'observed' record to that observer's sync history. This enables 22 + observers to verify end-to-end success via the segments API. 23 + """ 24 + observer_name = ctx.msg.get("observer") 25 + if not observer_name: 26 + return # Not an observer segment 27 + 28 + segment = ctx.msg.get("segment") 29 + day = ctx.msg.get("day") 30 + if not segment or not day: 31 + logger.warning( 32 + f"observe.observed missing segment/day for observer {observer_name}" 33 + ) 34 + return 35 + 36 + # Find observer by name to get key prefix 37 + observer = find_observer_by_name(observer_name) 38 + if not observer: 39 + logger.debug(f"Observer not found for observed event: {observer_name}") 40 + return 41 + 42 + key_prefix = observer.get("key", "")[:8] 43 + if not key_prefix: 44 + return 45 + 46 + # Append observed record to history 47 + record = { 48 + "ts": now_ms(), 49 + "type": "observed", 50 + "segment": segment, 51 + } 52 + append_history_record(key_prefix, day, record) 53 + 54 + # Update stats 55 + increment_stat(key_prefix, "segments_observed") 56 + 57 + logger.debug( 58 + f"Recorded observed status for observer {observer_name}: {day}/{segment}" 59 + )
+125
apps/observer/maint/000_migrate_remote_to_observer.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Migrate remote observer data and config to observer naming.""" 5 + 6 + from __future__ import annotations 7 + 8 + import argparse 9 + import json 10 + import logging 11 + import os 12 + from pathlib import Path 13 + 14 + from think.utils import get_journal, setup_cli 15 + 16 + logger = logging.getLogger(__name__) 17 + 18 + 19 + def _move_remote_storage(journal_path: Path) -> int: 20 + """Move legacy remote storage files into the observer app directory.""" 21 + source_root = journal_path / "apps" / "remote" / "remotes" 22 + target_root = journal_path / "apps" / "observer" / "observers" 23 + 24 + if not source_root.exists(): 25 + return 0 26 + 27 + moved = 0 28 + for source_path in sorted(source_root.rglob("*")): 29 + if source_path.is_dir(): 30 + continue 31 + 32 + relative_path = source_path.relative_to(source_root) 33 + target_path = target_root / relative_path 34 + target_path.parent.mkdir(parents=True, exist_ok=True) 35 + 36 + if target_path.exists(): 37 + if source_path.read_bytes() == target_path.read_bytes(): 38 + source_path.unlink() 39 + else: 40 + warning = ( 41 + f"Conflict: {target_path} already exists with different content; " 42 + f"leaving legacy file in place at {source_path}" 43 + ) 44 + logger.warning(warning) 45 + print(f"WARNING: {warning}") 46 + continue 47 + 48 + source_path.rename(target_path) 49 + moved += 1 50 + 51 + for directory in sorted(source_root.rglob("*"), reverse=True): 52 + if directory.is_dir(): 53 + try: 54 + directory.rmdir() 55 + except OSError: 56 + pass 57 + 58 + try: 59 + source_root.rmdir() 60 + except OSError: 61 + pass 62 + 63 + legacy_app_dir = journal_path / "apps" / "remote" 64 + try: 65 + legacy_app_dir.rmdir() 66 + except OSError: 67 + pass 68 + 69 + return moved 70 + 71 + 72 + def _migrate_config(journal_path: Path) -> bool: 73 + """Move observe.remote config into observe.observer.""" 74 + config_path = journal_path / "config" / "journal.json" 75 + if not config_path.exists(): 76 + return False 77 + 78 + try: 79 + with open(config_path, encoding="utf-8") as f: 80 + config = json.load(f) 81 + except (json.JSONDecodeError, OSError) as exc: 82 + logger.warning("Failed to read config %s: %s", config_path, exc) 83 + return False 84 + 85 + observe_config = config.get("observe") 86 + if not isinstance(observe_config, dict): 87 + return False 88 + 89 + remote_config = observe_config.get("remote") 90 + if not isinstance(remote_config, dict): 91 + return False 92 + 93 + observer_config = observe_config.setdefault("observer", {}) 94 + if not isinstance(observer_config, dict): 95 + observer_config = {} 96 + observe_config["observer"] = observer_config 97 + 98 + for key, value in remote_config.items(): 99 + observer_config.setdefault(key, value) 100 + 101 + del observe_config["remote"] 102 + 103 + with open(config_path, "w", encoding="utf-8") as f: 104 + json.dump(config, f, indent=2, ensure_ascii=False) 105 + f.write("\n") 106 + os.chmod(config_path, 0o600) 107 + return True 108 + 109 + 110 + def main() -> None: 111 + parser = argparse.ArgumentParser(description=__doc__.split("\n")[0]) 112 + setup_cli(parser) 113 + 114 + journal_path = Path(get_journal()) 115 + 116 + print("Migrating remote observer data to observer naming...") 117 + moved_files = _move_remote_storage(journal_path) 118 + config_updated = _migrate_config(journal_path) 119 + 120 + print(f" Storage files moved: {moved_files}") 121 + print(f" Config updated: {'yes' if config_updated else 'no'}") 122 + 123 + 124 + if __name__ == "__main__": 125 + main()
+1 -1
apps/remote/app.json apps/observer/app.json
··· 1 1 { 2 2 "icon": "📡", 3 - "label": "Remote", 3 + "label": "Observer", 4 4 "facets": {"disabled": true} 5 5 }
-60
apps/remote/events.py
··· 1 - # SPDX-License-Identifier: AGPL-3.0-only 2 - # Copyright (c) 2026 sol pbc 3 - 4 - """Remote app event handlers - track processing status of remote segments. 5 - 6 - Listens for observe.observed events for remote-originated segments and 7 - records their completion in the sync history. This enables remote observers 8 - to verify end-to-end processing success via the segments endpoint. 9 - """ 10 - 11 - import logging 12 - 13 - from apps.events import EventContext, on_event 14 - from think.utils import now_ms 15 - 16 - from .utils import append_history_record, find_remote_by_name, increment_stat 17 - 18 - logger = logging.getLogger(__name__) 19 - 20 - 21 - @on_event("observe", "observed") 22 - def handle_observed(ctx: EventContext) -> None: 23 - """Track observe.observed events for remote-originated segments. 24 - 25 - When a segment from a remote observer completes processing, append 26 - an 'observed' record to that remote's sync history. This enables 27 - remote observers to verify end-to-end success via the segments API. 28 - """ 29 - remote_name = ctx.msg.get("remote") 30 - if not remote_name: 31 - return # Not a remote segment 32 - 33 - segment = ctx.msg.get("segment") 34 - day = ctx.msg.get("day") 35 - if not segment or not day: 36 - logger.warning(f"observe.observed missing segment/day for remote {remote_name}") 37 - return 38 - 39 - # Find remote by name to get key prefix 40 - remote = find_remote_by_name(remote_name) 41 - if not remote: 42 - logger.debug(f"Remote not found for observed event: {remote_name}") 43 - return 44 - 45 - key_prefix = remote.get("key", "")[:8] 46 - if not key_prefix: 47 - return 48 - 49 - # Append observed record to history 50 - record = { 51 - "ts": now_ms(), 52 - "type": "observed", 53 - "segment": segment, 54 - } 55 - append_history_record(key_prefix, day, record) 56 - 57 - # Update stats 58 - increment_stat(key_prefix, "segments_observed") 59 - 60 - logger.debug(f"Recorded observed status for remote {remote_name}: {day}/{segment}")
+119 -119
apps/remote/routes.py apps/observer/routes.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Remote app - manage remote observer connections. 4 + """Observer app - manage observer connections. 5 5 6 6 Provides endpoints for: 7 - - Managing remote observer registrations (UI) 8 - - Receiving file uploads from remote observers (ingest) 9 - - Relaying events from remote observers to local Callosum 7 + - Managing observer registrations (UI) 8 + - Receiving file uploads from observers (ingest) 9 + - Relaying events from observers to local Callosum 10 10 - Retrieving segment upload history for sync verification 11 11 """ 12 12 ··· 36 36 from .utils import ( 37 37 append_history_record, 38 38 find_segment_by_sha256, 39 - get_remotes_dir, 40 - list_remotes, 39 + get_observers_dir, 40 + list_observers, 41 41 load_history, 42 - load_remote, 43 - save_remote, 42 + load_observer, 43 + save_observer, 44 44 ) 45 45 46 46 logger = logging.getLogger(__name__) 47 47 48 - remote_bp = Blueprint( 49 - "app:remote", 48 + observer_bp = Blueprint( 49 + "app:observer", 50 50 __name__, 51 - url_prefix="/app/remote", 51 + url_prefix="/app/observer", 52 52 ) 53 53 54 54 # Key length in bytes (256 bits = 32 bytes) ··· 66 66 67 67 68 68 def _generate_key() -> str: 69 - """Generate a URL-safe key for remote authentication.""" 69 + """Generate a URL-safe key for observer authentication.""" 70 70 return base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES)).decode().rstrip("=") 71 71 72 72 73 - def _revoke_remote(key: str) -> bool: 74 - """Revoke remote by key (soft-delete).""" 75 - remote = load_remote(key) 76 - if not remote: 73 + def _revoke_observer(key: str) -> bool: 74 + """Revoke observer by key (soft-delete).""" 75 + observer = load_observer(key) 76 + if not observer: 77 77 return False 78 - remote["revoked"] = True 79 - remote["revoked_at"] = now_ms() 80 - return save_remote(remote) 78 + observer["revoked"] = True 79 + observer["revoked_at"] = now_ms() 80 + return save_observer(observer) 81 81 82 82 83 83 # === Management API (session-protected) === 84 84 85 85 86 - @remote_bp.route("/api/list") 86 + @observer_bp.route("/api/list") 87 87 def api_list() -> Any: 88 - """List all registered remotes.""" 89 - remotes = list_remotes() 88 + """List all registered observers.""" 89 + observers = list_observers() 90 90 # Sanitize output - don't expose full keys 91 91 result = [] 92 - for r in remotes: 92 + for r in observers: 93 93 result.append( 94 94 { 95 95 "key_prefix": r.get("key", "")[:8], ··· 106 106 return jsonify(result) 107 107 108 108 109 - @remote_bp.route("/api/create", methods=["POST"]) 109 + @observer_bp.route("/api/create", methods=["POST"]) 110 110 def api_create() -> Any: 111 - """Create a new remote registration.""" 111 + """Create a new observer registration.""" 112 112 data = request.get_json(force=True) if request.is_json else {} 113 113 name = data.get("name", "").strip() 114 114 if not name: ··· 117 117 # Generate key 118 118 key = _generate_key() 119 119 120 - # Create remote record 121 - remote_data = { 120 + # Create observer record 121 + observer_data = { 122 122 "key": key, 123 123 "name": name, 124 124 "created_at": now_ms(), ··· 131 131 }, 132 132 } 133 133 134 - if not save_remote(remote_data): 135 - return jsonify({"error": "Failed to save remote"}), 500 134 + if not save_observer(observer_data): 135 + return jsonify({"error": "Failed to save observer"}), 500 136 136 137 137 # Log observer creation (journal-level, no facet) 138 138 log_app_action( 139 - app="remote", 139 + app="observer", 140 140 facet=None, 141 141 action="observer_create", 142 142 params={"name": name, "key_prefix": key[:8]}, 143 143 ) 144 144 145 145 # Build ingest URL 146 - ingest_url = f"/app/remote/ingest/{key}" 146 + ingest_url = f"/app/observer/ingest/{key}" 147 147 148 148 return jsonify( 149 149 { ··· 155 155 ) 156 156 157 157 158 - @remote_bp.route("/api/<key_prefix>", methods=["DELETE"]) 158 + @observer_bp.route("/api/<key_prefix>", methods=["DELETE"]) 159 159 def api_delete(key_prefix: str) -> Any: 160 - """Revoke a remote by key prefix (soft-delete).""" 161 - # Find remote by prefix 162 - remotes_dir = get_remotes_dir() 163 - remote_path = remotes_dir / f"{key_prefix}.json" 164 - if not remote_path.exists(): 165 - return jsonify({"error": "Remote not found"}), 404 160 + """Revoke an observer by key prefix (soft-delete).""" 161 + # Find observer by prefix 162 + observers_dir = get_observers_dir() 163 + observer_path = observers_dir / f"{key_prefix}.json" 164 + if not observer_path.exists(): 165 + return jsonify({"error": "Observer not found"}), 404 166 166 167 167 try: 168 - with open(remote_path) as f: 168 + with open(observer_path) as f: 169 169 data = json.load(f) 170 170 key = data.get("key", "") 171 171 name = data.get("name", "") 172 172 except (json.JSONDecodeError, OSError): 173 - return jsonify({"error": "Failed to read remote"}), 500 173 + return jsonify({"error": "Failed to read observer"}), 500 174 174 175 - if not _revoke_remote(key): 176 - return jsonify({"error": "Failed to revoke remote"}), 500 175 + if not _revoke_observer(key): 176 + return jsonify({"error": "Failed to revoke observer"}), 500 177 177 178 178 # Log observer revocation (journal-level, no facet) 179 179 log_app_action( 180 - app="remote", 180 + app="observer", 181 181 facet=None, 182 182 action="observer_revoke", 183 183 params={"name": name, "key_prefix": key_prefix}, ··· 186 186 return jsonify({"status": "ok"}) 187 187 188 188 189 - @remote_bp.route("/api/<key_prefix>/key") 189 + @observer_bp.route("/api/<key_prefix>/key") 190 190 def api_get_key(key_prefix: str) -> Any: 191 - """Get full key and ingest URL for a remote.""" 192 - # Find remote by prefix 193 - remotes_dir = get_remotes_dir() 194 - remote_path = remotes_dir / f"{key_prefix}.json" 195 - if not remote_path.exists(): 196 - return jsonify({"error": "Remote not found"}), 404 191 + """Get full key and ingest URL for an observer.""" 192 + # Find observer by prefix 193 + observers_dir = get_observers_dir() 194 + observer_path = observers_dir / f"{key_prefix}.json" 195 + if not observer_path.exists(): 196 + return jsonify({"error": "Observer not found"}), 404 197 197 198 198 try: 199 - with open(remote_path) as f: 199 + with open(observer_path) as f: 200 200 data = json.load(f) 201 201 except (json.JSONDecodeError, OSError): 202 - return jsonify({"error": "Failed to read remote"}), 500 202 + return jsonify({"error": "Failed to read observer"}), 500 203 203 204 204 key = data.get("key", "") 205 205 return jsonify( 206 206 { 207 207 "key": key, 208 208 "name": data.get("name", ""), 209 - "ingest_url": f"/app/remote/ingest/{key}", 209 + "ingest_url": f"/app/observer/ingest/{key}", 210 210 } 211 211 ) 212 212 ··· 278 278 Path to the failed directory where files were saved 279 279 """ 280 280 # Use segment in path for easier identification of failed uploads 281 - failed_dir = day_dir / "remote" / "failed" / segment / str(now_ms()) 281 + failed_dir = day_dir / "observer" / "failed" / segment / str(now_ms()) 282 282 failed_dir.mkdir(parents=True, exist_ok=True) 283 283 284 284 for submitted_filename, _simple_filename, content, _sha256 in file_data: ··· 291 291 # === Ingest API (key-protected) === 292 292 293 293 294 - @remote_bp.route("/ingest", methods=["POST"]) 295 - @remote_bp.route("/ingest/<key>", methods=["POST"]) 294 + @observer_bp.route("/ingest", methods=["POST"]) 295 + @observer_bp.route("/ingest/<key>", methods=["POST"]) 296 296 def ingest_upload(key: str | None = None) -> Any: 297 - """Receive file uploads from remote observer. 297 + """Receive file uploads from observer. 298 298 299 299 Expects multipart form with: 300 300 - segment: Segment key (HHMMSS_LEN) 301 301 - day: Day string (YYYYMMDD) 302 302 - files: One or more media files 303 - - host: (optional) Hostname of remote observer 304 - - platform: (optional) Platform of remote observer 303 + - host: (optional) Hostname of observer 304 + - platform: (optional) Platform of observer 305 305 - meta: (optional) JSON-encoded metadata dict (facet, setting, etc.) 306 306 307 307 Writes files to journal and emits observe.observing event. ··· 318 318 return jsonify({"error": "Authorization required"}), 401 319 319 320 320 # Validate key 321 - remote = load_remote(auth_key) 322 - if not remote: 321 + observer = load_observer(auth_key) 322 + if not observer: 323 323 return jsonify({"error": "Invalid key"}), 401 324 324 325 - if remote.get("revoked", False): 326 - return jsonify({"error": "Remote revoked"}), 403 325 + if observer.get("revoked", False): 326 + return jsonify({"error": "Observer revoked"}), 403 327 327 328 - if not remote.get("enabled", True): 329 - return jsonify({"error": "Remote disabled"}), 403 328 + if not observer.get("enabled", True): 329 + return jsonify({"error": "Observer disabled"}), 403 330 330 331 331 # Get segment, day, and host info from form 332 332 segment = request.form.get("segment", "").strip() ··· 341 341 try: 342 342 meta = json.loads(meta_str) 343 343 except json.JSONDecodeError: 344 - logger.warning(f"Invalid meta JSON from remote: {meta_str[:100]}") 344 + logger.warning(f"Invalid meta JSON from observer: {meta_str[:100]}") 345 345 if host and "host" not in meta: 346 346 meta["host"] = host 347 347 if platform and "platform" not in meta: 348 348 meta["platform"] = platform 349 349 350 - # Warn if client hostname differs from registered remote name 350 + # Warn if client hostname differs from registered observer name 351 351 effective_host = meta.get("host", host) 352 - remote_name = remote.get("name", "") 353 - if effective_host and effective_host != remote_name: 352 + observer_name = observer.get("name", "") 353 + if effective_host and effective_host != observer_name: 354 354 logger.warning( 355 - f"Remote '{remote_name}' ({auth_key[:8]}) connecting from host " 355 + f"Observer '{observer_name}' ({auth_key[:8]}) connecting from host " 356 356 f"'{effective_host}' — hostname differs from registered name. " 357 - f"Use `sol remote rename` to update if the host was renamed." 357 + f"Use `sol observer rename` to update if the host was renamed." 358 358 ) 359 359 360 360 if not segment: ··· 412 412 if existing_segment: 413 413 # Full duplicate - all files already exist in an existing segment 414 414 logger.info( 415 - f"Duplicate segment rejected: {day}/{segment} from {remote.get('name')} " 415 + f"Duplicate segment rejected: {day}/{segment} from {observer.get('name')} " 416 416 f"(matches existing {existing_segment})" 417 417 ) 418 418 419 419 # Update last_seen and increment duplicates_rejected stat 420 - remote["last_seen"] = now_ms() 421 - remote["stats"]["duplicates_rejected"] = ( 422 - remote["stats"].get("duplicates_rejected", 0) + 1 420 + observer["last_seen"] = now_ms() 421 + observer["stats"]["duplicates_rejected"] = ( 422 + observer["stats"].get("duplicates_rejected", 0) + 1 423 423 ) 424 - save_remote(remote) 424 + save_observer(observer) 425 425 426 426 return jsonify( 427 427 { ··· 439 439 day_dir.mkdir(parents=True, exist_ok=True) 440 440 441 441 # Determine stream name: trust client-provided stream in meta if valid, 442 - # otherwise derive from remote registration name. 443 - # Deriving from remote name via stream_name(remote=...) calls _strip_hostname, 442 + # otherwise derive from observer registration name. 443 + # Deriving from observer name via stream_name(observer=...) calls _strip_hostname, 444 444 # which strips qualifiers like ".tmux" — so "fedora.tmux" becomes "fedora", 445 445 # colliding both observers into one stream. 446 446 client_stream = meta.get("stream", "").strip() 447 - remote_name = remote.get("name", "unknown") 447 + observer_name = observer.get("name", "unknown") 448 448 if client_stream and re.match(r"^[a-z0-9][a-z0-9._-]*$", client_stream): 449 449 stream = client_stream 450 450 else: 451 - stream = stream_name(remote=remote_name) 451 + stream = stream_name(observer=observer_name) 452 452 453 453 # Find available segment key within the stream directory 454 454 stream_dir = day_dir / stream ··· 461 461 # Exhausted attempts, save to failed directory 462 462 logger.error( 463 463 f"No available segment slot for {day}/{stream}/{segment} from " 464 - f"{remote_name} after {MAX_SEGMENT_ATTEMPTS} attempts" 464 + f"{observer_name} after {MAX_SEGMENT_ATTEMPTS} attempts" 465 465 ) 466 466 failed_dir = _save_to_failed(day_dir, file_data, segment) 467 467 return ( ··· 479 479 if segment != original_segment: 480 480 logger.info( 481 481 f"Segment collision resolved: {original_segment} -> {segment} " 482 - f"for remote {remote_name}" 482 + f"for observer {observer_name}" 483 483 ) 484 484 485 485 # Create segment directory for files (under stream) ··· 534 534 sync_record["partial_match_sha256s"] = list(matched_sha256s) 535 535 append_history_record(key_prefix, day, sync_record) 536 536 537 - # Update remote stats 538 - remote["last_seen"] = now_ms() 539 - remote["last_segment"] = segment 540 - remote["stats"]["segments_received"] = ( 541 - remote["stats"].get("segments_received", 0) + 1 537 + # Update observer stats 538 + observer["last_seen"] = now_ms() 539 + observer["last_segment"] = segment 540 + observer["stats"]["segments_received"] = ( 541 + observer["stats"].get("segments_received", 0) + 1 542 542 ) 543 - remote["stats"]["bytes_received"] = ( 544 - remote["stats"].get("bytes_received", 0) + total_bytes 543 + observer["stats"]["bytes_received"] = ( 544 + observer["stats"].get("bytes_received", 0) + total_bytes 545 545 ) 546 - save_remote(remote) 546 + save_observer(observer) 547 547 548 548 # Write stream identity for this segment 549 549 try: 550 - result = update_stream(stream, day, segment, type="remote") 550 + result = update_stream(stream, day, segment, type="observer") 551 551 write_segment_stream( 552 552 segment_dir, 553 553 stream, ··· 567 567 "segment": segment, 568 568 "day": day, 569 569 "files": saved_files, 570 - "remote": remote_name, 570 + "observer": observer_name, 571 571 "stream": stream, 572 572 } 573 573 if meta: ··· 575 575 emit("observe", "observing", **event_fields) 576 576 577 577 logger.info( 578 - f"Received {len(saved_files)} files for {day}/{segment} from {remote.get('name')}" 578 + f"Received {len(saved_files)} files for {day}/{segment} from {observer.get('name')}" 579 579 ) 580 580 581 581 # Determine response status ··· 594 594 ) 595 595 596 596 597 - @remote_bp.route("/ingest/event", methods=["POST"]) 598 - @remote_bp.route("/ingest/<key>/event", methods=["POST"]) 597 + @observer_bp.route("/ingest/event", methods=["POST"]) 598 + @observer_bp.route("/ingest/<key>/event", methods=["POST"]) 599 599 def ingest_event(key: str | None = None) -> Any: 600 - """Receive events from remote observer and relay to local Callosum. 600 + """Receive events from observer and relay to local Callosum. 601 601 602 602 Expects JSON body with: 603 603 - tract: Event tract ··· 610 610 return jsonify({"error": "Authorization required"}), 401 611 611 612 612 # Validate key 613 - remote = load_remote(auth_key) 614 - if not remote: 613 + observer = load_observer(auth_key) 614 + if not observer: 615 615 return jsonify({"error": "Invalid key"}), 401 616 616 617 - if remote.get("revoked", False): 618 - return jsonify({"error": "Remote revoked"}), 403 617 + if observer.get("revoked", False): 618 + return jsonify({"error": "Observer revoked"}), 403 619 619 620 - if not remote.get("enabled", True): 621 - return jsonify({"error": "Remote disabled"}), 403 620 + if not observer.get("enabled", True): 621 + return jsonify({"error": "Observer disabled"}), 403 622 622 623 623 # Parse event 624 624 data = request.get_json(force=True) if request.is_json else {} ··· 629 629 if not tract or not event: 630 630 return jsonify({"error": "Missing tract or event"}), 400 631 631 632 - # Add remote identifier 633 - data["remote"] = remote.get("name", "unknown") 632 + # Add observer identifier 633 + data["observer"] = observer.get("name", "unknown") 634 634 635 635 # Relay to local Callosum 636 636 emit(tract, event, **{k: v for k, v in data.items() if k not in ("tract", "event")}) 637 637 638 638 # Update last_seen on status events 639 639 if tract == "observe" and event == "status": 640 - remote["last_seen"] = now_ms() 641 - save_remote(remote) 640 + observer["last_seen"] = now_ms() 641 + save_observer(observer) 642 642 643 643 return jsonify({"status": "ok"}) 644 644 645 645 646 - @remote_bp.route("/ingest/segments/<day>") 647 - @remote_bp.route("/ingest/<key>/segments/<day>") 646 + @observer_bp.route("/ingest/segments/<day>") 647 + @observer_bp.route("/ingest/<key>/segments/<day>") 648 648 def ingest_segments(day: str, key: str | None = None) -> Any: 649 649 """List uploaded segments for a day with file verification. 650 650 ··· 655 655 656 656 Args: 657 657 day: Day string (YYYYMMDD) 658 - key: Remote authentication key (from URL path, legacy) 658 + key: Observer authentication key (from URL path, legacy) 659 659 """ 660 660 # Extract key from Bearer header (primary) or URL path (legacy) 661 661 auth_key = _get_key(key) ··· 663 663 return jsonify({"error": "Authorization required"}), 401 664 664 665 665 # Validate key 666 - remote = load_remote(auth_key) 667 - if not remote: 666 + observer = load_observer(auth_key) 667 + if not observer: 668 668 return jsonify({"error": "Invalid key"}), 401 669 669 670 - if remote.get("revoked", False): 671 - return jsonify({"error": "Remote revoked"}), 403 670 + if observer.get("revoked", False): 671 + return jsonify({"error": "Observer revoked"}), 403 672 672 673 - if not remote.get("enabled", True): 674 - return jsonify({"error": "Remote disabled"}), 403 673 + if not observer.get("enabled", True): 674 + return jsonify({"error": "Observer disabled"}), 403 675 675 676 676 # Validate day format (YYYYMMDD) 677 677 if not re.match(r"^\d{8}$", day): 678 678 return jsonify({"error": "Invalid day format"}), 400 679 679 680 - # Load sync history for this remote/day 680 + # Load sync history for this observer/day 681 681 key_prefix = auth_key[:8] 682 682 records = load_history(key_prefix, day) 683 683 ··· 688 688 day_dir = day_path(day) 689 689 690 690 # Determine stream: trust client-provided query param if valid, 691 - # otherwise derive from remote name (same logic as ingest_upload). 691 + # otherwise derive from observer name (same logic as ingest_upload). 692 692 client_stream = request.args.get("stream", "").strip() 693 - remote_name = remote.get("name", "unknown") 693 + observer_name = observer.get("name", "unknown") 694 694 if client_stream and re.match(r"^[a-z0-9][a-z0-9._-]*$", client_stream): 695 695 stream = client_stream 696 696 else: 697 - stream = stream_name(remote=remote_name) 697 + stream = stream_name(observer=observer_name) 698 698 699 699 # Build response grouped by segment, deduplicating by sha256 700 700 # Later records overwrite earlier ones (most recent upload wins)
+3 -3
apps/remote/tests/conftest.py apps/observer/tests/conftest.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Self-contained fixtures for remote app tests. 4 + """Self-contained fixtures for observer app tests. 5 5 6 6 These fixtures are fully standalone and only depend on pytest builtins. 7 7 No shared dependencies from the root conftest.py are required. ··· 13 13 14 14 15 15 @pytest.fixture 16 - def remote_env(tmp_path, monkeypatch): 17 - """Create a temporary journal for remote app testing. 16 + def observer_env(tmp_path, monkeypatch): 17 + """Create a temporary journal for observer app testing. 18 18 19 19 Returns a factory function that sets up the environment and returns 20 20 the Flask test client along with the journal path.
+2 -2
apps/remote/tests/test_client.py apps/observer/tests/test_client.py
··· 23 23 """Test RemoteClient initialization.""" 24 24 from observe.sync import RemoteClient 25 25 26 - client = RemoteClient("https://server:5000/app/remote/ingest/abc123") 26 + client = RemoteClient("https://server:5000/app/observer/ingest/abc123") 27 27 28 - assert client.remote_url == "https://server:5000/app/remote/ingest/abc123" 28 + assert client.remote_url == "https://server:5000/app/observer/ingest/abc123" 29 29 30 30 31 31 def test_upload_segment_success(mock_session, tmp_path):
+44 -44
apps/remote/tests/test_events.py apps/observer/tests/test_events.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Tests for remote app event handlers.""" 4 + """Tests for observer app event handlers.""" 5 5 6 6 from __future__ import annotations 7 7 ··· 10 10 import pytest 11 11 12 12 from apps.events import EventContext 13 - from apps.remote.events import handle_observed 13 + from apps.observer.events import handle_observed 14 14 15 15 16 16 @pytest.fixture 17 - def remote_journal(tmp_path, monkeypatch): 18 - """Create a temporary journal with a remote registered.""" 17 + def observer_journal(tmp_path, monkeypatch): 18 + """Create a temporary journal with a observer registered.""" 19 19 from convey import state 20 20 21 21 journal = tmp_path / "journal" ··· 24 24 # Set convey state (used by apps.utils for storage paths) 25 25 monkeypatch.setattr(state, "journal_root", str(journal)) 26 26 27 - # Create remotes directory 28 - remotes_dir = journal / "apps" / "remote" / "remotes" 29 - remotes_dir.mkdir(parents=True) 27 + # Create observers directory 28 + observers_dir = journal / "apps" / "observer" / "observers" 29 + observers_dir.mkdir(parents=True) 30 30 31 - # Create a test remote 32 - remote_data = { 31 + # Create a test observer 32 + observer_data = { 33 33 "key": "testkey123456789abcdef", 34 - "name": "test-remote", 34 + "name": "test-observer", 35 35 "created_at": 1704312000000, 36 36 "last_seen": None, 37 37 "last_segment": None, ··· 41 41 "bytes_received": 1024, 42 42 }, 43 43 } 44 - remote_path = remotes_dir / "testkey1.json" 45 - with open(remote_path, "w") as f: 46 - json.dump(remote_data, f) 44 + observer_path = observers_dir / "testkey1.json" 45 + with open(observer_path, "w") as f: 46 + json.dump(observer_data, f) 47 47 48 48 class Env: 49 49 def __init__(self): 50 50 self.journal = journal 51 - self.remotes_dir = remotes_dir 52 - self.remote_path = remote_path 51 + self.observers_dir = observers_dir 52 + self.observer_path = observer_path 53 53 54 54 return Env() 55 55 ··· 57 57 class TestHandleObserved: 58 58 """Tests for handle_observed event handler.""" 59 59 60 - def test_records_observed_for_remote(self, remote_journal): 61 - """Handler records observed status for remote segment.""" 60 + def test_records_observed_for_observer(self, observer_journal): 61 + """Handler records observed status for observer segment.""" 62 62 ctx = EventContext( 63 63 msg={ 64 64 "tract": "observe", 65 65 "event": "observed", 66 - "remote": "test-remote", 66 + "observer": "test-observer", 67 67 "segment": "120000_300", 68 68 "day": "20250103", 69 69 }, 70 - app="remote", 70 + app="observer", 71 71 tract="observe", 72 72 event="observed", 73 73 ) ··· 75 75 handle_observed(ctx) 76 76 77 77 # Check history was written 78 - hist_path = remote_journal.remotes_dir / "testkey1" / "hist" / "20250103.jsonl" 78 + hist_path = observer_journal.observers_dir / "testkey1" / "hist" / "20250103.jsonl" 79 79 assert hist_path.exists() 80 80 81 81 with open(hist_path) as f: ··· 86 86 assert "ts" in record 87 87 88 88 # Check stat was incremented 89 - with open(remote_journal.remote_path) as f: 89 + with open(observer_journal.observer_path) as f: 90 90 data = json.load(f) 91 91 assert data["stats"]["segments_observed"] == 1 92 92 93 - def test_multiple_observed_events(self, remote_journal): 93 + def test_multiple_observed_events(self, observer_journal): 94 94 """Handler appends multiple observed records.""" 95 95 for segment in ["120000_300", "130000_300", "140000_300"]: 96 96 ctx = EventContext( 97 97 msg={ 98 98 "tract": "observe", 99 99 "event": "observed", 100 - "remote": "test-remote", 100 + "observer": "test-observer", 101 101 "segment": segment, 102 102 "day": "20250103", 103 103 }, 104 - app="remote", 104 + app="observer", 105 105 tract="observe", 106 106 event="observed", 107 107 ) 108 108 handle_observed(ctx) 109 109 110 110 # Check all records written 111 - hist_path = remote_journal.remotes_dir / "testkey1" / "hist" / "20250103.jsonl" 111 + hist_path = observer_journal.observers_dir / "testkey1" / "hist" / "20250103.jsonl" 112 112 with open(hist_path) as f: 113 113 lines = f.readlines() 114 114 ··· 118 118 assert json.loads(lines[2])["segment"] == "140000_300" 119 119 120 120 # Check stat incremented 3 times 121 - with open(remote_journal.remote_path) as f: 121 + with open(observer_journal.observer_path) as f: 122 122 data = json.load(f) 123 123 assert data["stats"]["segments_observed"] == 3 124 124 125 - def test_ignores_non_remote_events(self, remote_journal): 126 - """Handler ignores events without remote field.""" 125 + def test_ignores_non_observer_events(self, observer_journal): 126 + """Handler ignores events without observer field.""" 127 127 ctx = EventContext( 128 128 msg={ 129 129 "tract": "observe", ··· 131 131 "segment": "120000_300", 132 132 "day": "20250103", 133 133 }, 134 - app="remote", 134 + app="observer", 135 135 tract="observe", 136 136 event="observed", 137 137 ) ··· 139 139 handle_observed(ctx) 140 140 141 141 # No history should be created 142 - hist_dir = remote_journal.remotes_dir / "testkey1" / "hist" 142 + hist_dir = observer_journal.observers_dir / "testkey1" / "hist" 143 143 assert not hist_dir.exists() 144 144 145 - def test_ignores_unknown_remote(self, remote_journal): 146 - """Handler ignores events for unknown remotes.""" 145 + def test_ignores_unknown_observer(self, observer_journal): 146 + """Handler ignores events for unknown observers.""" 147 147 ctx = EventContext( 148 148 msg={ 149 149 "tract": "observe", 150 150 "event": "observed", 151 - "remote": "unknown-remote", 151 + "observer": "unknown-observer", 152 152 "segment": "120000_300", 153 153 "day": "20250103", 154 154 }, 155 - app="remote", 155 + app="observer", 156 156 tract="observe", 157 157 event="observed", 158 158 ) 159 159 160 160 handle_observed(ctx) 161 161 162 - # No history should be created for unknown remote 163 - hist_dir = remote_journal.remotes_dir / "testkey1" / "hist" 162 + # No history should be created for unknown observer 163 + hist_dir = observer_journal.observers_dir / "testkey1" / "hist" 164 164 assert not hist_dir.exists() 165 165 166 - def test_handles_missing_segment(self, remote_journal): 166 + def test_handles_missing_segment(self, observer_journal): 167 167 """Handler handles events missing segment field.""" 168 168 ctx = EventContext( 169 169 msg={ 170 170 "tract": "observe", 171 171 "event": "observed", 172 - "remote": "test-remote", 172 + "observer": "test-observer", 173 173 "day": "20250103", 174 174 }, 175 - app="remote", 175 + app="observer", 176 176 tract="observe", 177 177 event="observed", 178 178 ) ··· 181 181 handle_observed(ctx) 182 182 183 183 # No history should be created 184 - hist_dir = remote_journal.remotes_dir / "testkey1" / "hist" 184 + hist_dir = observer_journal.observers_dir / "testkey1" / "hist" 185 185 assert not hist_dir.exists() 186 186 187 - def test_handles_missing_day(self, remote_journal): 187 + def test_handles_missing_day(self, observer_journal): 188 188 """Handler handles events missing day field.""" 189 189 ctx = EventContext( 190 190 msg={ 191 191 "tract": "observe", 192 192 "event": "observed", 193 - "remote": "test-remote", 193 + "observer": "test-observer", 194 194 "segment": "120000_300", 195 195 }, 196 - app="remote", 196 + app="observer", 197 197 tract="observe", 198 198 event="observed", 199 199 ) ··· 202 202 handle_observed(ctx) 203 203 204 204 # No history should be created 205 - hist_dir = remote_journal.remotes_dir / "testkey1" / "hist" 205 + hist_dir = observer_journal.observers_dir / "testkey1" / "hist" 206 206 assert not hist_dir.exists()
+41 -41
apps/remote/tests/test_observer_client.py apps/observer/tests/test_observer_client.py
··· 14 14 15 15 @pytest.fixture 16 16 def mock_session(): 17 - with patch("observe.remote_client.requests.Session") as mock: 17 + with patch("observe.observer_client.requests.Session") as mock: 18 18 session = MagicMock() 19 19 mock.return_value = session 20 20 yield session ··· 23 23 @pytest.fixture 24 24 def mock_config(): 25 25 with ( 26 - patch("observe.remote_client.get_config") as mock, 27 - patch("observe.remote_client.read_service_port") as mock_port, 26 + patch("observe.observer_client.get_config") as mock, 27 + patch("observe.observer_client.read_service_port") as mock_port, 28 28 ): 29 29 mock.return_value = {} 30 30 mock_port.return_value = 8000 ··· 33 33 34 34 @pytest.fixture 35 35 def mock_journal(tmp_path): 36 - with patch("observe.remote_client.get_journal") as mock: 36 + with patch("observe.observer_client.get_journal") as mock: 37 37 mock.return_value = str(tmp_path) 38 38 yield tmp_path 39 39 40 40 41 41 def test_observer_client_init(mock_session, mock_config): 42 - from observe.remote_client import ObserverClient 42 + from observe.observer_client import ObserverClient 43 43 44 44 client = ObserverClient("main-stream") 45 45 ··· 52 52 53 53 def test_observer_client_init_no_port(mock_session): 54 54 """When no config URL and no convey.port file, _url is empty.""" 55 - from observe.remote_client import ObserverClient 55 + from observe.observer_client import ObserverClient 56 56 57 57 with ( 58 - patch("observe.remote_client.get_config") as cfg, 59 - patch("observe.remote_client.read_service_port") as port, 58 + patch("observe.observer_client.get_config") as cfg, 59 + patch("observe.observer_client.read_service_port") as port, 60 60 ): 61 61 cfg.return_value = {} 62 62 port.return_value = None ··· 66 66 67 67 68 68 def test_observer_client_init_with_config(mock_session, mock_config): 69 - from observe.remote_client import ObserverClient 69 + from observe.observer_client import ObserverClient 70 70 71 71 mock_config.return_value = { 72 72 "observe": { 73 - "remote": { 73 + "observer": { 74 74 "url": "https://example.test/", 75 75 "key": "abc123", 76 76 "name": "named-observer", ··· 88 88 89 89 90 90 def test_auto_registration(mock_session, mock_config, mock_journal, tmp_path): 91 - from observe.remote_client import ObserverClient 91 + from observe.observer_client import ObserverClient 92 92 93 93 file1 = tmp_path / "audio.flac" 94 94 file1.write_bytes(b"audio") ··· 108 108 109 109 assert result.success is True 110 110 assert client._key == "registered-key" 111 - assert mock_session.post.call_args_list[0][0][0].endswith("/app/remote/api/create") 111 + assert mock_session.post.call_args_list[0][0][0].endswith("/app/observer/api/create") 112 112 config = json.loads((mock_journal / "config" / "journal.json").read_text()) 113 - assert config["observe"]["remote"]["key"] == "registered-key" 113 + assert config["observe"]["observer"]["key"] == "registered-key" 114 114 115 115 116 116 def test_existing_key_skips_registration(mock_session, mock_config, tmp_path): 117 - from observe.remote_client import ObserverClient 117 + from observe.observer_client import ObserverClient 118 118 119 - mock_config.return_value = {"observe": {"remote": {"key": "testkey123"}}} 119 + mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}} 120 120 121 121 file1 = tmp_path / "audio.flac" 122 122 file1.write_bytes(b"audio") ··· 131 131 132 132 assert result.success is True 133 133 assert mock_session.post.call_count == 1 134 - assert mock_session.post.call_args[0][0].endswith("/app/remote/ingest/testkey123") 134 + assert mock_session.post.call_args[0][0].endswith("/app/observer/ingest/testkey123") 135 135 136 136 137 137 def test_registration_retry(mock_session, mock_config, mock_journal, tmp_path): 138 - from observe.remote_client import ObserverClient 138 + from observe.observer_client import ObserverClient 139 139 140 140 file1 = tmp_path / "audio.flac" 141 141 file1.write_bytes(b"audio") ··· 154 154 upload_response, 155 155 ] 156 156 157 - with patch("observe.remote_client.time.sleep"): 157 + with patch("observe.observer_client.time.sleep"): 158 158 client = ObserverClient("main-stream") 159 159 result = client.upload_segment("20250103", "120000_300", [file1]) 160 160 ··· 163 163 164 164 165 165 def test_registration_403(mock_session, mock_config): 166 - from observe.remote_client import ObserverClient 166 + from observe.observer_client import ObserverClient 167 167 168 168 response = MagicMock() 169 169 response.status_code = 403 ··· 177 177 178 178 179 179 def test_upload_segment_success(mock_session, mock_config, tmp_path): 180 - from observe.remote_client import ObserverClient 180 + from observe.observer_client import ObserverClient 181 181 182 - mock_config.return_value = {"observe": {"remote": {"key": "testkey123"}}} 182 + mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}} 183 183 184 184 file1 = tmp_path / "audio.flac" 185 185 file1.write_bytes(b"audio data") ··· 197 197 198 198 199 199 def test_upload_segment_retry(mock_session, mock_config, tmp_path): 200 - from observe.remote_client import ObserverClient 200 + from observe.observer_client import ObserverClient 201 201 202 - mock_config.return_value = {"observe": {"remote": {"key": "testkey123"}}} 202 + mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}} 203 203 204 204 file1 = tmp_path / "audio.flac" 205 205 file1.write_bytes(b"audio data") ··· 214 214 215 215 mock_session.post.side_effect = [failure, success] 216 216 217 - with patch("observe.remote_client.time.sleep"): 217 + with patch("observe.observer_client.time.sleep"): 218 218 client = ObserverClient("main-stream") 219 219 result = client.upload_segment("20250103", "120000_300", [file1]) 220 220 ··· 223 223 224 224 225 225 def test_upload_segment_403(mock_session, mock_config, tmp_path): 226 - from observe.remote_client import ObserverClient 226 + from observe.observer_client import ObserverClient 227 227 228 - mock_config.return_value = {"observe": {"remote": {"key": "testkey123"}}} 228 + mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}} 229 229 230 230 file1 = tmp_path / "audio.flac" 231 231 file1.write_bytes(b"audio data") ··· 243 243 244 244 245 245 def test_upload_segment_all_retries_fail(mock_session, mock_config, tmp_path): 246 - from observe.remote_client import ObserverClient 246 + from observe.observer_client import ObserverClient 247 247 248 - mock_config.return_value = {"observe": {"remote": {"key": "testkey123"}}} 248 + mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}} 249 249 250 250 file1 = tmp_path / "audio.flac" 251 251 file1.write_bytes(b"audio data") ··· 255 255 failure.text = "Server error" 256 256 mock_session.post.return_value = failure 257 257 258 - with patch("observe.remote_client.time.sleep"): 258 + with patch("observe.observer_client.time.sleep"): 259 259 client = ObserverClient("main-stream") 260 260 result = client.upload_segment("20250103", "120000_300", [file1]) 261 261 ··· 264 264 265 265 266 266 def test_relay_event_success(mock_session, mock_config): 267 - from observe.remote_client import ObserverClient 267 + from observe.observer_client import ObserverClient 268 268 269 - mock_config.return_value = {"observe": {"remote": {"key": "testkey123"}}} 269 + mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}} 270 270 271 271 response = MagicMock() 272 272 response.status_code = 200 ··· 284 284 285 285 286 286 def test_relay_event_403(mock_session, mock_config): 287 - from observe.remote_client import ObserverClient 287 + from observe.observer_client import ObserverClient 288 288 289 - mock_config.return_value = {"observe": {"remote": {"key": "testkey123"}}} 289 + mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}} 290 290 291 291 response = MagicMock() 292 292 response.status_code = 403 ··· 301 301 302 302 303 303 def test_key_persistence(mock_session, mock_config, mock_journal): 304 - from observe.remote_client import ObserverClient 304 + from observe.observer_client import ObserverClient 305 305 306 306 client = ObserverClient("main-stream") 307 307 client._persist_key("persisted-key") 308 308 309 309 config = json.loads((mock_journal / "config" / "journal.json").read_text()) 310 - assert config == {"observe": {"remote": {"key": "persisted-key"}}} 310 + assert config == {"observe": {"observer": {"key": "persisted-key"}}} 311 311 312 312 313 313 def test_key_persistence_preserves_existing(mock_session, mock_config, mock_journal): 314 - from observe.remote_client import ObserverClient 314 + from observe.observer_client import ObserverClient 315 315 316 316 config_dir = mock_journal / "config" 317 317 config_dir.mkdir() ··· 328 328 config = json.loads(config_path.read_text()) 329 329 assert config["identity"]["name"] == "Jer" 330 330 assert config["observe"]["tmux"]["enabled"] is True 331 - assert config["observe"]["remote"]["key"] == "persisted-key" 331 + assert config["observe"]["observer"]["key"] == "persisted-key" 332 332 333 333 334 334 def test_cleanup_draft(tmp_path): 335 - from observe.remote_client import cleanup_draft 335 + from observe.observer_client import cleanup_draft 336 336 337 337 draft_dir = tmp_path / "draft" 338 338 draft_dir.mkdir() ··· 345 345 346 346 347 347 def test_finalize_draft(tmp_path): 348 - from observe.remote_client import finalize_draft 348 + from observe.observer_client import finalize_draft 349 349 350 350 draft_dir = tmp_path / "091551_draft" 351 351 draft_dir.mkdir() ··· 363 363 364 364 365 365 def test_upload_duplicate_response(mock_session, mock_config, tmp_path): 366 - from observe.remote_client import ObserverClient 366 + from observe.observer_client import ObserverClient 367 367 368 - mock_config.return_value = {"observe": {"remote": {"key": "testkey123"}}} 368 + mock_config.return_value = {"observe": {"observer": {"key": "testkey123"}}} 369 369 370 370 file1 = tmp_path / "audio.flac" 371 371 file1.write_bytes(b"audio data")
+304 -304
apps/remote/tests/test_routes.py apps/observer/tests/test_routes.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Tests for remote app routes.""" 4 + """Tests for observer app routes.""" 5 5 6 6 from __future__ import annotations 7 7 ··· 9 9 import json 10 10 11 11 12 - def test_api_list_empty(remote_env): 13 - """Test listing remotes when none exist.""" 14 - env = remote_env() 12 + def test_api_list_empty(observer_env): 13 + """Test listing observers when none exist.""" 14 + env = observer_env() 15 15 16 - resp = env.client.get("/app/remote/api/list") 16 + resp = env.client.get("/app/observer/api/list") 17 17 assert resp.status_code == 200 18 18 assert resp.get_json() == [] 19 19 20 20 21 - def test_api_create_remote(remote_env): 22 - """Test creating a new remote.""" 23 - env = remote_env() 21 + def test_api_create_observer(observer_env): 22 + """Test creating a new observer.""" 23 + env = observer_env() 24 24 25 25 resp = env.client.post( 26 - "/app/remote/api/create", 26 + "/app/observer/api/create", 27 27 json={"name": "test-laptop"}, 28 28 content_type="application/json", 29 29 ) ··· 35 35 assert len(data["key"]) > 32 # 256 bits = 43 base64 chars 36 36 assert data["key_prefix"] == data["key"][:8] 37 37 assert data["name"] == "test-laptop" 38 - assert "/app/remote/ingest/" in data["ingest_url"] 38 + assert "/app/observer/ingest/" in data["ingest_url"] 39 39 40 40 41 - def test_api_create_requires_name(remote_env): 42 - """Test that creating a remote requires a name.""" 43 - env = remote_env() 41 + def test_api_create_requires_name(observer_env): 42 + """Test that creating a observer requires a name.""" 43 + env = observer_env() 44 44 45 45 # Missing name 46 46 resp = env.client.post( 47 - "/app/remote/api/create", 47 + "/app/observer/api/create", 48 48 json={}, 49 49 content_type="application/json", 50 50 ) ··· 53 53 54 54 # Empty name 55 55 resp = env.client.post( 56 - "/app/remote/api/create", 56 + "/app/observer/api/create", 57 57 json={"name": " "}, 58 58 content_type="application/json", 59 59 ) 60 60 assert resp.status_code == 400 61 61 62 62 63 - def test_api_list_shows_created_remote(remote_env): 64 - """Test that created remotes appear in the list.""" 65 - env = remote_env() 63 + def test_api_list_shows_created_observer(observer_env): 64 + """Test that created observers appear in the list.""" 65 + env = observer_env() 66 66 67 - # Create a remote 67 + # Create a observer 68 68 resp = env.client.post( 69 - "/app/remote/api/create", 70 - json={"name": "my-remote"}, 69 + "/app/observer/api/create", 70 + json={"name": "my-observer"}, 71 71 content_type="application/json", 72 72 ) 73 73 assert resp.status_code == 200 74 74 key_prefix = resp.get_json()["key_prefix"] 75 75 76 76 # List should show it 77 - resp = env.client.get("/app/remote/api/list") 77 + resp = env.client.get("/app/observer/api/list") 78 78 assert resp.status_code == 200 79 - remotes = resp.get_json() 79 + observers = resp.get_json() 80 80 81 - assert len(remotes) == 1 82 - assert remotes[0]["key_prefix"] == key_prefix 83 - assert remotes[0]["name"] == "my-remote" 84 - assert remotes[0]["enabled"] is True 85 - assert remotes[0]["stats"]["segments_received"] == 0 81 + assert len(observers) == 1 82 + assert observers[0]["key_prefix"] == key_prefix 83 + assert observers[0]["name"] == "my-observer" 84 + assert observers[0]["enabled"] is True 85 + assert observers[0]["stats"]["segments_received"] == 0 86 86 87 87 88 - def test_api_delete_remote(remote_env): 89 - """Test revoking a remote (soft-delete).""" 90 - env = remote_env() 88 + def test_api_delete_observer(observer_env): 89 + """Test revoking a observer (soft-delete).""" 90 + env = observer_env() 91 91 92 - # Create a remote 92 + # Create a observer 93 93 resp = env.client.post( 94 - "/app/remote/api/create", 94 + "/app/observer/api/create", 95 95 json={"name": "to-revoke"}, 96 96 content_type="application/json", 97 97 ) 98 98 key_prefix = resp.get_json()["key_prefix"] 99 99 100 100 # Revoke it 101 - resp = env.client.delete(f"/app/remote/api/{key_prefix}") 101 + resp = env.client.delete(f"/app/observer/api/{key_prefix}") 102 102 assert resp.status_code == 200 103 103 assert resp.get_json()["status"] == "ok" 104 104 105 105 # List should still show it, but marked as revoked 106 - resp = env.client.get("/app/remote/api/list") 107 - remotes = resp.get_json() 108 - assert len(remotes) == 1 109 - assert remotes[0]["key_prefix"] == key_prefix 110 - assert remotes[0]["revoked"] is True 111 - assert remotes[0]["revoked_at"] is not None 106 + resp = env.client.get("/app/observer/api/list") 107 + observers = resp.get_json() 108 + assert len(observers) == 1 109 + assert observers[0]["key_prefix"] == key_prefix 110 + assert observers[0]["revoked"] is True 111 + assert observers[0]["revoked_at"] is not None 112 112 113 113 114 - def test_api_delete_nonexistent(remote_env): 115 - """Test deleting a nonexistent remote returns 404.""" 116 - env = remote_env() 114 + def test_api_delete_nonexistent(observer_env): 115 + """Test deleting a nonexistent observer returns 404.""" 116 + env = observer_env() 117 117 118 - resp = env.client.delete("/app/remote/api/nonexistent") 118 + resp = env.client.delete("/app/observer/api/nonexistent") 119 119 assert resp.status_code == 404 120 120 121 121 122 - def test_ingest_invalid_key(remote_env): 122 + def test_ingest_invalid_key(observer_env): 123 123 """Test that ingest rejects invalid keys.""" 124 - env = remote_env() 124 + env = observer_env() 125 125 126 126 resp = env.client.post( 127 - "/app/remote/ingest", 127 + "/app/observer/ingest", 128 128 headers={"Authorization": "Bearer invalid-key-12345"}, 129 129 data={"day": "20250103", "segment": "120000_300"}, 130 130 ) ··· 132 132 assert "Invalid key" in resp.get_json()["error"] 133 133 134 134 135 - def test_ingest_missing_segment(remote_env): 135 + def test_ingest_missing_segment(observer_env): 136 136 """Test that ingest requires segment.""" 137 - env = remote_env() 137 + env = observer_env() 138 138 139 - # Create a remote 139 + # Create a observer 140 140 resp = env.client.post( 141 - "/app/remote/api/create", 141 + "/app/observer/api/create", 142 142 json={"name": "test"}, 143 143 content_type="application/json", 144 144 ) ··· 146 146 147 147 # Upload without segment 148 148 resp = env.client.post( 149 - "/app/remote/ingest", 149 + "/app/observer/ingest", 150 150 headers={"Authorization": f"Bearer {key}"}, 151 151 data={"day": "20250103"}, 152 152 ) ··· 154 154 assert "Missing segment" in resp.get_json()["error"] 155 155 156 156 157 - def test_ingest_missing_day(remote_env): 157 + def test_ingest_missing_day(observer_env): 158 158 """Test that ingest requires day.""" 159 - env = remote_env() 159 + env = observer_env() 160 160 161 - # Create a remote 161 + # Create a observer 162 162 resp = env.client.post( 163 - "/app/remote/api/create", 163 + "/app/observer/api/create", 164 164 json={"name": "test"}, 165 165 content_type="application/json", 166 166 ) ··· 168 168 169 169 # Upload without day 170 170 resp = env.client.post( 171 - "/app/remote/ingest", 171 + "/app/observer/ingest", 172 172 headers={"Authorization": f"Bearer {key}"}, 173 173 data={"segment": "120000_300"}, 174 174 ) ··· 176 176 assert "Missing day" in resp.get_json()["error"] 177 177 178 178 179 - def test_ingest_invalid_segment_format(remote_env): 179 + def test_ingest_invalid_segment_format(observer_env): 180 180 """Test that ingest validates segment format.""" 181 - env = remote_env() 181 + env = observer_env() 182 182 183 - # Create a remote 183 + # Create a observer 184 184 resp = env.client.post( 185 - "/app/remote/api/create", 185 + "/app/observer/api/create", 186 186 json={"name": "test"}, 187 187 content_type="application/json", 188 188 ) ··· 190 190 191 191 # Invalid segment format 192 192 resp = env.client.post( 193 - "/app/remote/ingest", 193 + "/app/observer/ingest", 194 194 headers={"Authorization": f"Bearer {key}"}, 195 195 data={"day": "20250103", "segment": "invalid"}, 196 196 ) ··· 198 198 assert "Invalid segment format" in resp.get_json()["error"] 199 199 200 200 201 - def test_ingest_invalid_day_format(remote_env): 201 + def test_ingest_invalid_day_format(observer_env): 202 202 """Test that ingest validates day format.""" 203 - env = remote_env() 203 + env = observer_env() 204 204 205 - # Create a remote 205 + # Create a observer 206 206 resp = env.client.post( 207 - "/app/remote/api/create", 207 + "/app/observer/api/create", 208 208 json={"name": "test"}, 209 209 content_type="application/json", 210 210 ) ··· 212 212 213 213 # Invalid day format 214 214 resp = env.client.post( 215 - "/app/remote/ingest", 215 + "/app/observer/ingest", 216 216 headers={"Authorization": f"Bearer {key}"}, 217 217 data={"day": "2025-01-03", "segment": "120000_300"}, 218 218 ) ··· 220 220 assert "Invalid day format" in resp.get_json()["error"] 221 221 222 222 223 - def test_ingest_no_files(remote_env): 223 + def test_ingest_no_files(observer_env): 224 224 """Test that ingest requires files.""" 225 - env = remote_env() 225 + env = observer_env() 226 226 227 - # Create a remote 227 + # Create a observer 228 228 resp = env.client.post( 229 - "/app/remote/api/create", 229 + "/app/observer/api/create", 230 230 json={"name": "test"}, 231 231 content_type="application/json", 232 232 ) ··· 234 234 235 235 # Upload without files 236 236 resp = env.client.post( 237 - "/app/remote/ingest", 237 + "/app/observer/ingest", 238 238 headers={"Authorization": f"Bearer {key}"}, 239 239 data={"day": "20250103", "segment": "120000_300"}, 240 240 ) ··· 242 242 assert "No files uploaded" in resp.get_json()["error"] 243 243 244 244 245 - def test_ingest_success(remote_env): 245 + def test_ingest_success(observer_env): 246 246 """Test successful file ingest.""" 247 - env = remote_env() 247 + env = observer_env() 248 248 249 - # Create a remote 249 + # Create a observer 250 250 resp = env.client.post( 251 - "/app/remote/api/create", 252 - json={"name": "test-remote"}, 251 + "/app/observer/api/create", 252 + json={"name": "test-observer"}, 253 253 content_type="application/json", 254 254 ) 255 255 key = resp.get_json()["key"] ··· 257 257 # Upload a file 258 258 test_data = b"test audio content" 259 259 resp = env.client.post( 260 - "/app/remote/ingest", 260 + "/app/observer/ingest", 261 261 headers={"Authorization": f"Bearer {key}"}, 262 262 data={ 263 263 "day": "20250103", ··· 273 273 274 274 # Verify file was written (in stream/segment directory) 275 275 expected_file = ( 276 - env.journal / "20250103" / "test-remote" / "120000_300" / "test_audio.flac" 276 + env.journal / "20250103" / "test-observer" / "120000_300" / "test_audio.flac" 277 277 ) 278 278 assert expected_file.exists() 279 279 assert expected_file.read_bytes() == test_data 280 280 281 281 282 - def test_ingest_updates_stats(remote_env): 283 - """Test that ingest updates remote stats.""" 284 - env = remote_env() 282 + def test_ingest_updates_stats(observer_env): 283 + """Test that ingest updates observer stats.""" 284 + env = observer_env() 285 285 286 - # Create a remote 286 + # Create a observer 287 287 resp = env.client.post( 288 - "/app/remote/api/create", 288 + "/app/observer/api/create", 289 289 json={"name": "stats-test"}, 290 290 content_type="application/json", 291 291 ) ··· 294 294 # Upload a file 295 295 test_data = b"test content" 296 296 resp = env.client.post( 297 - "/app/remote/ingest", 297 + "/app/observer/ingest", 298 298 headers={"Authorization": f"Bearer {key}"}, 299 299 data={ 300 300 "day": "20250103", ··· 305 305 assert resp.status_code == 200 306 306 307 307 # Check stats updated 308 - resp = env.client.get("/app/remote/api/list") 309 - remotes = resp.get_json() 310 - assert len(remotes) == 1 311 - assert remotes[0]["stats"]["segments_received"] == 1 312 - assert remotes[0]["stats"]["bytes_received"] == len(test_data) 313 - assert remotes[0]["last_segment"] == "120000_300" 314 - assert remotes[0]["last_seen"] is not None 308 + resp = env.client.get("/app/observer/api/list") 309 + observers = resp.get_json() 310 + assert len(observers) == 1 311 + assert observers[0]["stats"]["segments_received"] == 1 312 + assert observers[0]["stats"]["bytes_received"] == len(test_data) 313 + assert observers[0]["last_segment"] == "120000_300" 314 + assert observers[0]["last_seen"] is not None 315 315 316 316 317 - def test_ingest_event_relay(remote_env): 317 + def test_ingest_event_relay(observer_env): 318 318 """Test event relay endpoint.""" 319 - env = remote_env() 319 + env = observer_env() 320 320 321 - # Create a remote 321 + # Create a observer 322 322 resp = env.client.post( 323 - "/app/remote/api/create", 323 + "/app/observer/api/create", 324 324 json={"name": "event-test"}, 325 325 content_type="application/json", 326 326 ) ··· 328 328 329 329 # Send an event 330 330 resp = env.client.post( 331 - "/app/remote/ingest/event", 331 + "/app/observer/ingest/event", 332 332 headers={"Authorization": f"Bearer {key}"}, 333 333 json={"tract": "observe", "event": "status", "mode": "screencast"}, 334 334 content_type="application/json", ··· 337 337 assert resp.get_json()["status"] == "ok" 338 338 339 339 340 - def test_ingest_event_missing_tract(remote_env): 340 + def test_ingest_event_missing_tract(observer_env): 341 341 """Test that event relay requires tract.""" 342 - env = remote_env() 342 + env = observer_env() 343 343 344 - # Create a remote 344 + # Create a observer 345 345 resp = env.client.post( 346 - "/app/remote/api/create", 346 + "/app/observer/api/create", 347 347 json={"name": "test"}, 348 348 content_type="application/json", 349 349 ) ··· 351 351 352 352 # Missing tract 353 353 resp = env.client.post( 354 - "/app/remote/ingest/event", 354 + "/app/observer/ingest/event", 355 355 headers={"Authorization": f"Bearer {key}"}, 356 356 json={"event": "status"}, 357 357 content_type="application/json", ··· 360 360 assert "Missing tract or event" in resp.get_json()["error"] 361 361 362 362 363 - def test_ingest_revoked_key(remote_env): 363 + def test_ingest_revoked_key(observer_env): 364 364 """Test that ingest rejects revoked keys.""" 365 - env = remote_env() 365 + env = observer_env() 366 366 367 - # Create and revoke a remote 367 + # Create and revoke a observer 368 368 resp = env.client.post( 369 - "/app/remote/api/create", 369 + "/app/observer/api/create", 370 370 json={"name": "revoked-test"}, 371 371 content_type="application/json", 372 372 ) ··· 374 374 key = data["key"] 375 375 key_prefix = data["key_prefix"] 376 376 377 - resp = env.client.delete(f"/app/remote/api/{key_prefix}") 377 + resp = env.client.delete(f"/app/observer/api/{key_prefix}") 378 378 assert resp.status_code == 200 379 379 380 380 # Try to upload - should fail 381 381 test_data = b"test content" 382 382 resp = env.client.post( 383 - "/app/remote/ingest", 383 + "/app/observer/ingest", 384 384 headers={"Authorization": f"Bearer {key}"}, 385 385 data={ 386 386 "day": "20250103", ··· 389 389 }, 390 390 ) 391 391 assert resp.status_code == 403 392 - assert "Remote revoked" in resp.get_json()["error"] 392 + assert "Observer revoked" in resp.get_json()["error"] 393 393 394 394 395 - def test_ingest_event_revoked_key(remote_env): 395 + def test_ingest_event_revoked_key(observer_env): 396 396 """Test that event relay rejects revoked keys.""" 397 - env = remote_env() 397 + env = observer_env() 398 398 399 - # Create and revoke a remote 399 + # Create and revoke a observer 400 400 resp = env.client.post( 401 - "/app/remote/api/create", 401 + "/app/observer/api/create", 402 402 json={"name": "revoked-event-test"}, 403 403 content_type="application/json", 404 404 ) ··· 406 406 key = data["key"] 407 407 key_prefix = data["key_prefix"] 408 408 409 - resp = env.client.delete(f"/app/remote/api/{key_prefix}") 409 + resp = env.client.delete(f"/app/observer/api/{key_prefix}") 410 410 assert resp.status_code == 200 411 411 412 412 # Try to send event - should fail 413 413 resp = env.client.post( 414 - "/app/remote/ingest/event", 414 + "/app/observer/ingest/event", 415 415 headers={"Authorization": f"Bearer {key}"}, 416 416 json={"tract": "observe", "event": "status"}, 417 417 content_type="application/json", 418 418 ) 419 419 assert resp.status_code == 403 420 - assert "Remote revoked" in resp.get_json()["error"] 420 + assert "Observer revoked" in resp.get_json()["error"] 421 421 422 422 423 - def test_api_get_key(remote_env): 424 - """Test retrieving full key for a remote.""" 425 - env = remote_env() 423 + def test_api_get_key(observer_env): 424 + """Test retrieving full key for a observer.""" 425 + env = observer_env() 426 426 427 - # Create a remote 427 + # Create a observer 428 428 resp = env.client.post( 429 - "/app/remote/api/create", 429 + "/app/observer/api/create", 430 430 json={"name": "key-test"}, 431 431 content_type="application/json", 432 432 ) ··· 435 435 key_prefix = create_data["key_prefix"] 436 436 437 437 # Get the key 438 - resp = env.client.get(f"/app/remote/api/{key_prefix}/key") 438 + resp = env.client.get(f"/app/observer/api/{key_prefix}/key") 439 439 assert resp.status_code == 200 440 440 441 441 data = resp.get_json() 442 442 assert data["key"] == key 443 443 assert data["name"] == "key-test" 444 - assert data["ingest_url"] == f"/app/remote/ingest/{key}" 444 + assert data["ingest_url"] == f"/app/observer/ingest/{key}" 445 445 446 446 447 - def test_api_get_key_nonexistent(remote_env): 448 - """Test getting key for nonexistent remote returns 404.""" 449 - env = remote_env() 447 + def test_api_get_key_nonexistent(observer_env): 448 + """Test getting key for nonexistent observer returns 404.""" 449 + env = observer_env() 450 450 451 - resp = env.client.get("/app/remote/api/nonexistent/key") 451 + resp = env.client.get("/app/observer/api/nonexistent/key") 452 452 assert resp.status_code == 404 453 453 454 454 455 455 # === Segment collision helper tests === 456 456 457 457 458 - def test_find_available_segment_no_conflict(remote_env): 458 + def test_find_available_segment_no_conflict(observer_env): 459 459 """Test find_available_segment returns original when no conflict.""" 460 460 from observe.utils import find_available_segment 461 461 462 - env = remote_env() 462 + env = observer_env() 463 463 day_dir = env.journal / "20250103" 464 464 day_dir.mkdir(parents=True) 465 465 ··· 467 467 assert result == "120000_300" 468 468 469 469 470 - def test_find_available_segment_with_conflict(remote_env): 470 + def test_find_available_segment_with_conflict(observer_env): 471 471 """Test find_available_segment finds alternative when conflict exists.""" 472 472 from observe.utils import find_available_segment 473 473 474 - env = remote_env() 474 + env = observer_env() 475 475 day_dir = env.journal / "20250103" 476 476 day_dir.mkdir(parents=True) 477 477 ··· 490 490 assert dur_part.isdigit() 491 491 492 492 493 - def test_find_available_segment_with_limited_attempts(remote_env): 493 + def test_find_available_segment_with_limited_attempts(observer_env): 494 494 """Test find_available_segment respects max_attempts limit.""" 495 495 from observe.utils import find_available_segment 496 496 497 - env = remote_env() 497 + env = observer_env() 498 498 day_dir = env.journal / "20250103" 499 499 day_dir.mkdir(parents=True) 500 500 ··· 506 506 assert result is None 507 507 508 508 509 - def test_save_to_failed_creates_directory(remote_env): 509 + def test_save_to_failed_creates_directory(observer_env): 510 510 """Test _save_to_failed creates failed directory structure.""" 511 - from apps.remote.routes import _save_to_failed 511 + from apps.observer.routes import _save_to_failed 512 512 513 - env = remote_env() 513 + env = observer_env() 514 514 day_dir = env.journal / "20250103" 515 515 day_dir.mkdir(parents=True) 516 516 ··· 524 524 525 525 # Verify structure includes segment key 526 526 assert failed_dir.exists() 527 - assert "remote/failed/120000_300/" in str(failed_dir) 527 + assert "observer/failed/120000_300/" in str(failed_dir) 528 528 assert (failed_dir / "120000_300_audio.flac").exists() 529 529 assert (failed_dir / "120000_300_screen.webm").exists() 530 530 # Verify actual content was written ··· 535 535 # === Integration tests for collision handling === 536 536 537 537 538 - def test_ingest_collision_adjusts_segment(remote_env): 538 + def test_ingest_collision_adjusts_segment(observer_env): 539 539 """Test that ingest adjusts segment key on collision.""" 540 - env = remote_env() 540 + env = observer_env() 541 541 542 - # Create a remote 542 + # Create a observer 543 543 resp = env.client.post( 544 - "/app/remote/api/create", 544 + "/app/observer/api/create", 545 545 json={"name": "collision-test"}, 546 546 content_type="application/json", 547 547 ) ··· 557 557 # Upload with same segment key 558 558 test_data = b"new audio content" 559 559 resp = env.client.post( 560 - "/app/remote/ingest", 560 + "/app/observer/ingest", 561 561 headers={"Authorization": f"Bearer {key}"}, 562 562 data={ 563 563 "day": "20250103", ··· 584 584 assert (adjusted_segments[0] / "audio.flac").exists() 585 585 586 586 587 - def test_ingest_no_collision_preserves_segment(remote_env): 587 + def test_ingest_no_collision_preserves_segment(observer_env): 588 588 """Test that ingest preserves segment key when no collision.""" 589 - env = remote_env() 589 + env = observer_env() 590 590 591 - # Create a remote 591 + # Create a observer 592 592 resp = env.client.post( 593 - "/app/remote/api/create", 593 + "/app/observer/api/create", 594 594 json={"name": "no-collision-test"}, 595 595 content_type="application/json", 596 596 ) ··· 599 599 # Upload without any conflicting segment directory 600 600 test_data = b"audio content" 601 601 resp = env.client.post( 602 - "/app/remote/ingest", 602 + "/app/observer/ingest", 603 603 headers={"Authorization": f"Bearer {key}"}, 604 604 data={ 605 605 "day": "20250103", ··· 620 620 assert expected_file.exists() 621 621 622 622 623 - def test_ingest_stats_use_adjusted_segment(remote_env): 624 - """Test that remote stats record the adjusted segment key.""" 625 - env = remote_env() 623 + def test_ingest_stats_use_adjusted_segment(observer_env): 624 + """Test that observer stats record the adjusted segment key.""" 625 + env = observer_env() 626 626 627 - # Create a remote 627 + # Create a observer 628 628 resp = env.client.post( 629 - "/app/remote/api/create", 629 + "/app/observer/api/create", 630 630 json={"name": "stats-adjust-test"}, 631 631 content_type="application/json", 632 632 ) ··· 641 641 # Upload with same segment key 642 642 test_data = b"new audio" 643 643 resp = env.client.post( 644 - "/app/remote/ingest", 644 + "/app/observer/ingest", 645 645 headers={"Authorization": f"Bearer {key}"}, 646 646 data={ 647 647 "day": "20250103", ··· 653 653 assert resp.status_code == 200 654 654 655 655 # Check stats - last_segment should be the adjusted one 656 - resp = env.client.get("/app/remote/api/list") 657 - remotes = resp.get_json() 658 - assert len(remotes) == 1 659 - last_segment = remotes[0]["last_segment"] 656 + resp = env.client.get("/app/observer/api/list") 657 + observers = resp.get_json() 658 + assert len(observers) == 1 659 + last_segment = observers[0]["last_segment"] 660 660 assert last_segment is not None 661 661 # It should be adjusted (not the original conflicting one) 662 662 assert last_segment != "120000_300" ··· 667 667 # === Sync history tests === 668 668 669 669 670 - def test_ingest_creates_sync_history(remote_env): 670 + def test_ingest_creates_sync_history(observer_env): 671 671 """Test that ingest creates sync history record.""" 672 - env = remote_env() 672 + env = observer_env() 673 673 674 - # Create a remote 674 + # Create a observer 675 675 resp = env.client.post( 676 - "/app/remote/api/create", 676 + "/app/observer/api/create", 677 677 json={"name": "history-test"}, 678 678 content_type="application/json", 679 679 ) ··· 684 684 # Upload a file 685 685 test_data = b"test audio content for history" 686 686 resp = env.client.post( 687 - "/app/remote/ingest", 687 + "/app/observer/ingest", 688 688 headers={"Authorization": f"Bearer {key}"}, 689 689 data={ 690 690 "day": "20250103", ··· 698 698 hist_path = ( 699 699 env.journal 700 700 / "apps" 701 - / "remote" 702 - / "remotes" 701 + / "observer" 702 + / "observers" 703 703 / key_prefix 704 704 / "hist" 705 705 / "20250103.jsonl" ··· 722 722 assert file_rec["inode"] > 0 723 723 724 724 725 - def test_ingest_history_with_collision(remote_env): 725 + def test_ingest_history_with_collision(observer_env): 726 726 """Test that sync history records collision adjustment.""" 727 - env = remote_env() 727 + env = observer_env() 728 728 729 - # Create a remote 729 + # Create a observer 730 730 resp = env.client.post( 731 - "/app/remote/api/create", 731 + "/app/observer/api/create", 732 732 json={"name": "collision-history-test"}, 733 733 content_type="application/json", 734 734 ) ··· 745 745 # Upload with same segment key 746 746 test_data = b"new audio content" 747 747 resp = env.client.post( 748 - "/app/remote/ingest", 748 + "/app/observer/ingest", 749 749 headers={"Authorization": f"Bearer {key}"}, 750 750 data={ 751 751 "day": "20250103", ··· 759 759 hist_path = ( 760 760 env.journal 761 761 / "apps" 762 - / "remote" 763 - / "remotes" 762 + / "observer" 763 + / "observers" 764 764 / key_prefix 765 765 / "hist" 766 766 / "20250103.jsonl" ··· 778 778 assert file_rec["written"] == "audio.flac" # Segment prefix stripped 779 779 780 780 781 - def test_segments_endpoint_empty(remote_env): 781 + def test_segments_endpoint_empty(observer_env): 782 782 """Test segments endpoint returns empty for no uploads.""" 783 - env = remote_env() 783 + env = observer_env() 784 784 785 - # Create a remote 785 + # Create a observer 786 786 resp = env.client.post( 787 - "/app/remote/api/create", 787 + "/app/observer/api/create", 788 788 json={"name": "segments-empty-test"}, 789 789 content_type="application/json", 790 790 ) ··· 792 792 793 793 # Query segments - should be empty 794 794 resp = env.client.get( 795 - "/app/remote/ingest/segments/20250103", 795 + "/app/observer/ingest/segments/20250103", 796 796 headers={"Authorization": f"Bearer {key}"}, 797 797 ) 798 798 assert resp.status_code == 200 799 799 assert resp.get_json() == [] 800 800 801 801 802 - def test_segments_endpoint_invalid_key(remote_env): 802 + def test_segments_endpoint_invalid_key(observer_env): 803 803 """Test segments endpoint rejects invalid key.""" 804 - env = remote_env() 804 + env = observer_env() 805 805 806 806 resp = env.client.get( 807 - "/app/remote/ingest/segments/20250103", 807 + "/app/observer/ingest/segments/20250103", 808 808 headers={"Authorization": "Bearer invalid-key"}, 809 809 ) 810 810 assert resp.status_code == 401 811 811 812 812 813 - def test_segments_endpoint_invalid_day(remote_env): 813 + def test_segments_endpoint_invalid_day(observer_env): 814 814 """Test segments endpoint validates day format.""" 815 - env = remote_env() 815 + env = observer_env() 816 816 817 - # Create a remote 817 + # Create a observer 818 818 resp = env.client.post( 819 - "/app/remote/api/create", 819 + "/app/observer/api/create", 820 820 json={"name": "segments-day-test"}, 821 821 content_type="application/json", 822 822 ) 823 823 key = resp.get_json()["key"] 824 824 825 825 resp = env.client.get( 826 - "/app/remote/ingest/segments/2025-01-03", 826 + "/app/observer/ingest/segments/2025-01-03", 827 827 headers={"Authorization": f"Bearer {key}"}, 828 828 ) 829 829 assert resp.status_code == 400 830 830 assert "Invalid day format" in resp.get_json()["error"] 831 831 832 832 833 - def test_segments_endpoint_lists_uploads(remote_env): 833 + def test_segments_endpoint_lists_uploads(observer_env): 834 834 """Test segments endpoint lists uploaded segments.""" 835 - env = remote_env() 835 + env = observer_env() 836 836 837 - # Create a remote 837 + # Create a observer 838 838 resp = env.client.post( 839 - "/app/remote/api/create", 839 + "/app/observer/api/create", 840 840 json={"name": "segments-list-test"}, 841 841 content_type="application/json", 842 842 ) ··· 845 845 # Upload a file 846 846 test_data = b"test audio content" 847 847 resp = env.client.post( 848 - "/app/remote/ingest", 848 + "/app/observer/ingest", 849 849 headers={"Authorization": f"Bearer {key}"}, 850 850 data={ 851 851 "day": "20250103", ··· 857 857 858 858 # Query segments 859 859 resp = env.client.get( 860 - "/app/remote/ingest/segments/20250103", 860 + "/app/observer/ingest/segments/20250103", 861 861 headers={"Authorization": f"Bearer {key}"}, 862 862 ) 863 863 assert resp.status_code == 200 ··· 880 880 ) # Original name preserved 881 881 882 882 883 - def test_segments_endpoint_shows_collision(remote_env): 883 + def test_segments_endpoint_shows_collision(observer_env): 884 884 """Test segments endpoint shows collision info.""" 885 - env = remote_env() 885 + env = observer_env() 886 886 887 - # Create a remote 887 + # Create a observer 888 888 resp = env.client.post( 889 - "/app/remote/api/create", 889 + "/app/observer/api/create", 890 890 json={"name": "segments-collision-test"}, 891 891 content_type="application/json", 892 892 ) ··· 901 901 # Upload with collision 902 902 test_data = b"new audio" 903 903 resp = env.client.post( 904 - "/app/remote/ingest", 904 + "/app/observer/ingest", 905 905 headers={"Authorization": f"Bearer {key}"}, 906 906 data={ 907 907 "day": "20250103", ··· 913 913 914 914 # Query segments 915 915 resp = env.client.get( 916 - "/app/remote/ingest/segments/20250103", 916 + "/app/observer/ingest/segments/20250103", 917 917 headers={"Authorization": f"Bearer {key}"}, 918 918 ) 919 919 data = resp.get_json() ··· 929 929 assert file_info["status"] == "present" 930 930 931 931 932 - def test_segments_endpoint_missing_file(remote_env): 932 + def test_segments_endpoint_missing_file(observer_env): 933 933 """Test segments endpoint reports missing files.""" 934 - env = remote_env() 934 + env = observer_env() 935 935 936 - # Create a remote 936 + # Create a observer 937 937 resp = env.client.post( 938 - "/app/remote/api/create", 938 + "/app/observer/api/create", 939 939 json={"name": "segments-missing-test"}, 940 940 content_type="application/json", 941 941 ) ··· 944 944 # Upload a file 945 945 test_data = b"test audio" 946 946 resp = env.client.post( 947 - "/app/remote/ingest", 947 + "/app/observer/ingest", 948 948 headers={"Authorization": f"Bearer {key}"}, 949 949 data={ 950 950 "day": "20250103", ··· 961 961 962 962 # Query segments 963 963 resp = env.client.get( 964 - "/app/remote/ingest/segments/20250103", 964 + "/app/observer/ingest/segments/20250103", 965 965 headers={"Authorization": f"Bearer {key}"}, 966 966 ) 967 967 data = resp.get_json() ··· 971 971 assert file_info["status"] == "missing" 972 972 973 973 974 - def test_segments_endpoint_relocated_file(remote_env): 974 + def test_segments_endpoint_relocated_file(observer_env): 975 975 """Test segments endpoint detects relocated files by inode.""" 976 - env = remote_env() 976 + env = observer_env() 977 977 978 - # Create a remote 978 + # Create a observer 979 979 resp = env.client.post( 980 - "/app/remote/api/create", 980 + "/app/observer/api/create", 981 981 json={"name": "segments-relocate-test"}, 982 982 content_type="application/json", 983 983 ) ··· 986 986 # Upload a file 987 987 test_data = b"test audio for relocation" 988 988 resp = env.client.post( 989 - "/app/remote/ingest", 989 + "/app/observer/ingest", 990 990 headers={"Authorization": f"Bearer {key}"}, 991 991 data={ 992 992 "day": "20250103", ··· 1005 1005 1006 1006 # Query segments - should detect relocation by inode 1007 1007 resp = env.client.get( 1008 - "/app/remote/ingest/segments/20250103", 1008 + "/app/observer/ingest/segments/20250103", 1009 1009 headers={"Authorization": f"Bearer {key}"}, 1010 1010 ) 1011 1011 data = resp.get_json() ··· 1019 1019 ) 1020 1020 1021 1021 1022 - def test_find_by_inode(remote_env): 1022 + def test_find_by_inode(observer_env): 1023 1023 """Test _find_by_inode helper.""" 1024 - from apps.remote.routes import _find_by_inode 1024 + from apps.observer.routes import _find_by_inode 1025 1025 1026 - env = remote_env() 1026 + env = observer_env() 1027 1027 day_dir = env.journal / "20250103" 1028 1028 day_dir.mkdir(parents=True) 1029 1029 ··· 1051 1051 assert found is None 1052 1052 1053 1053 1054 - def test_segments_endpoint_revoked_key(remote_env): 1054 + def test_segments_endpoint_revoked_key(observer_env): 1055 1055 """Test segments endpoint rejects revoked key.""" 1056 - env = remote_env() 1056 + env = observer_env() 1057 1057 1058 - # Create and revoke a remote 1058 + # Create and revoke a observer 1059 1059 resp = env.client.post( 1060 - "/app/remote/api/create", 1060 + "/app/observer/api/create", 1061 1061 json={"name": "segments-revoked-test"}, 1062 1062 content_type="application/json", 1063 1063 ) ··· 1065 1065 key = data["key"] 1066 1066 key_prefix = data["key_prefix"] 1067 1067 1068 - env.client.delete(f"/app/remote/api/{key_prefix}") 1068 + env.client.delete(f"/app/observer/api/{key_prefix}") 1069 1069 1070 1070 # Query segments - should be rejected 1071 1071 resp = env.client.get( 1072 - "/app/remote/ingest/segments/20250103", 1072 + "/app/observer/ingest/segments/20250103", 1073 1073 headers={"Authorization": f"Bearer {key}"}, 1074 1074 ) 1075 1075 assert resp.status_code == 403 1076 - assert "Remote revoked" in resp.get_json()["error"] 1076 + assert "Observer revoked" in resp.get_json()["error"] 1077 1077 1078 1078 1079 - def test_segments_endpoint_deduplicates_by_sha256(remote_env): 1079 + def test_segments_endpoint_deduplicates_by_sha256(observer_env): 1080 1080 """Test that duplicate file uploads are rejected (not duplicated on disk). 1081 1081 1082 1082 With duplicate detection enabled, re-uploading the same content returns 1083 1083 status='duplicate' and the segment is not written again. 1084 1084 """ 1085 - env = remote_env() 1085 + env = observer_env() 1086 1086 1087 - # Create a remote 1087 + # Create a observer 1088 1088 resp = env.client.post( 1089 - "/app/remote/api/create", 1089 + "/app/observer/api/create", 1090 1090 json={"name": "segments-dedup-test"}, 1091 1091 content_type="application/json", 1092 1092 ) ··· 1095 1095 # Upload a file 1096 1096 test_data = b"test audio content" 1097 1097 resp = env.client.post( 1098 - "/app/remote/ingest", 1098 + "/app/observer/ingest", 1099 1099 headers={"Authorization": f"Bearer {key}"}, 1100 1100 data={ 1101 1101 "day": "20250103", ··· 1109 1109 # Upload the same file again (same content = same sha256) 1110 1110 # With duplicate detection, this should be rejected 1111 1111 resp = env.client.post( 1112 - "/app/remote/ingest", 1112 + "/app/observer/ingest", 1113 1113 headers={"Authorization": f"Bearer {key}"}, 1114 1114 data={ 1115 1115 "day": "20250103", ··· 1122 1122 1123 1123 # Query segments - should have only one segment (duplicate was rejected) 1124 1124 resp = env.client.get( 1125 - "/app/remote/ingest/segments/20250103", 1125 + "/app/observer/ingest/segments/20250103", 1126 1126 headers={"Authorization": f"Bearer {key}"}, 1127 1127 ) 1128 1128 data = resp.get_json() ··· 1134 1134 assert data[0]["files"][0]["status"] == "present" 1135 1135 1136 1136 1137 - def test_segments_endpoint_shows_observed_status(remote_env): 1137 + def test_segments_endpoint_shows_observed_status(observer_env): 1138 1138 """Test that segments endpoint includes observed status.""" 1139 - env = remote_env() 1139 + env = observer_env() 1140 1140 1141 - # Create a remote 1141 + # Create a observer 1142 1142 resp = env.client.post( 1143 - "/app/remote/api/create", 1143 + "/app/observer/api/create", 1144 1144 json={"name": "observed-test"}, 1145 1145 content_type="application/json", 1146 1146 ) ··· 1151 1151 # Upload a file 1152 1152 test_data = b"test audio content" 1153 1153 resp = env.client.post( 1154 - "/app/remote/ingest", 1154 + "/app/observer/ingest", 1155 1155 headers={"Authorization": f"Bearer {key}"}, 1156 1156 data={ 1157 1157 "day": "20250103", ··· 1163 1163 1164 1164 # Query segments - should show observed: false 1165 1165 resp = env.client.get( 1166 - "/app/remote/ingest/segments/20250103", 1166 + "/app/observer/ingest/segments/20250103", 1167 1167 headers={"Authorization": f"Bearer {key}"}, 1168 1168 ) 1169 1169 data = resp.get_json() ··· 1171 1171 assert data[0]["observed"] is False 1172 1172 1173 1173 # Manually add an observed record to simulate event handler 1174 - hist_dir = env.journal / "apps" / "remote" / "remotes" / key_prefix / "hist" 1174 + hist_dir = env.journal / "apps" / "observer" / "observers" / key_prefix / "hist" 1175 1175 hist_dir.mkdir(parents=True, exist_ok=True) 1176 1176 hist_path = hist_dir / "20250103.jsonl" 1177 1177 with open(hist_path, "a") as f: ··· 1179 1179 1180 1180 # Query again - should now show observed: true 1181 1181 resp = env.client.get( 1182 - "/app/remote/ingest/segments/20250103", 1182 + "/app/observer/ingest/segments/20250103", 1183 1183 headers={"Authorization": f"Bearer {key}"}, 1184 1184 ) 1185 1185 data = resp.get_json() ··· 1187 1187 assert data[0]["observed"] is True 1188 1188 1189 1189 1190 - def test_api_list_includes_segments_observed_stat(remote_env): 1190 + def test_api_list_includes_segments_observed_stat(observer_env): 1191 1191 """Test that api_list includes segments_observed stat.""" 1192 - env = remote_env() 1192 + env = observer_env() 1193 1193 1194 - # Create a remote 1194 + # Create a observer 1195 1195 resp = env.client.post( 1196 - "/app/remote/api/create", 1196 + "/app/observer/api/create", 1197 1197 json={"name": "stats-test"}, 1198 1198 content_type="application/json", 1199 1199 ) ··· 1201 1201 key_prefix = data["key_prefix"] 1202 1202 1203 1203 # Initially no segments_observed 1204 - resp = env.client.get("/app/remote/api/list") 1204 + resp = env.client.get("/app/observer/api/list") 1205 1205 data = resp.get_json() 1206 1206 assert len(data) == 1 1207 1207 assert "segments_observed" not in data[0]["stats"] 1208 1208 1209 1209 # Manually add segments_observed stat 1210 - remote_path = env.journal / "apps" / "remote" / "remotes" / f"{key_prefix}.json" 1211 - with open(remote_path) as f: 1212 - remote_data = json.load(f) 1213 - remote_data["stats"]["segments_observed"] = 5 1214 - with open(remote_path, "w") as f: 1215 - json.dump(remote_data, f) 1210 + observer_path = env.journal / "apps" / "observer" / "observers" / f"{key_prefix}.json" 1211 + with open(observer_path) as f: 1212 + observer_data = json.load(f) 1213 + observer_data["stats"]["segments_observed"] = 5 1214 + with open(observer_path, "w") as f: 1215 + json.dump(observer_data, f) 1216 1216 1217 1217 # Should now show in list 1218 - resp = env.client.get("/app/remote/api/list") 1218 + resp = env.client.get("/app/observer/api/list") 1219 1219 data = resp.get_json() 1220 1220 assert data[0]["stats"]["segments_observed"] == 5 1221 1221 ··· 1223 1223 # === Duplicate detection tests === 1224 1224 1225 1225 1226 - def test_ingest_duplicate_segment_returns_duplicate_status(remote_env): 1226 + def test_ingest_duplicate_segment_returns_duplicate_status(observer_env): 1227 1227 """Test that re-submitting identical files returns duplicate status.""" 1228 - env = remote_env() 1228 + env = observer_env() 1229 1229 1230 - # Create a remote 1230 + # Create a observer 1231 1231 resp = env.client.post( 1232 - "/app/remote/api/create", 1232 + "/app/observer/api/create", 1233 1233 json={"name": "duplicate-test"}, 1234 1234 content_type="application/json", 1235 1235 ) ··· 1238 1238 # First upload 1239 1239 test_data = b"test audio content for duplicate test" 1240 1240 resp = env.client.post( 1241 - "/app/remote/ingest", 1241 + "/app/observer/ingest", 1242 1242 headers={"Authorization": f"Bearer {key}"}, 1243 1243 data={ 1244 1244 "day": "20250103", ··· 1253 1253 1254 1254 # Second upload with identical content 1255 1255 resp = env.client.post( 1256 - "/app/remote/ingest", 1256 + "/app/observer/ingest", 1257 1257 headers={"Authorization": f"Bearer {key}"}, 1258 1258 data={ 1259 1259 "day": "20250103", ··· 1268 1268 assert "message" in data 1269 1269 1270 1270 1271 - def test_ingest_duplicate_does_not_emit_event(remote_env, monkeypatch): 1271 + def test_ingest_duplicate_does_not_emit_event(observer_env, monkeypatch): 1272 1272 """Test that duplicate submission does not emit observe.observing event.""" 1273 1273 from unittest.mock import MagicMock 1274 1274 1275 - env = remote_env() 1275 + env = observer_env() 1276 1276 1277 1277 # Mock emit 1278 - import apps.remote.routes as routes_module 1278 + import apps.observer.routes as routes_module 1279 1279 1280 1280 emit_mock = MagicMock() 1281 1281 monkeypatch.setattr(routes_module, "emit", emit_mock) 1282 1282 1283 - # Create a remote 1283 + # Create a observer 1284 1284 resp = env.client.post( 1285 - "/app/remote/api/create", 1285 + "/app/observer/api/create", 1286 1286 json={"name": "no-event-test"}, 1287 1287 content_type="application/json", 1288 1288 ) ··· 1292 1292 1293 1293 # First upload - should emit 1294 1294 resp = env.client.post( 1295 - "/app/remote/ingest", 1295 + "/app/observer/ingest", 1296 1296 headers={"Authorization": f"Bearer {key}"}, 1297 1297 data={ 1298 1298 "day": "20250103", ··· 1305 1305 1306 1306 # Second upload - should NOT emit 1307 1307 resp = env.client.post( 1308 - "/app/remote/ingest", 1308 + "/app/observer/ingest", 1309 1309 headers={"Authorization": f"Bearer {key}"}, 1310 1310 data={ 1311 1311 "day": "20250103", ··· 1318 1318 assert emit_mock.call_count == 1 # No new emit 1319 1319 1320 1320 1321 - def test_ingest_duplicate_increments_duplicates_rejected_stat(remote_env): 1321 + def test_ingest_duplicate_increments_duplicates_rejected_stat(observer_env): 1322 1322 """Test that duplicate submission increments duplicates_rejected stat.""" 1323 - env = remote_env() 1323 + env = observer_env() 1324 1324 1325 - # Create a remote 1325 + # Create a observer 1326 1326 resp = env.client.post( 1327 - "/app/remote/api/create", 1327 + "/app/observer/api/create", 1328 1328 json={"name": "dup-stat-test"}, 1329 1329 content_type="application/json", 1330 1330 ) ··· 1334 1334 1335 1335 # First upload 1336 1336 resp = env.client.post( 1337 - "/app/remote/ingest", 1337 + "/app/observer/ingest", 1338 1338 headers={"Authorization": f"Bearer {key}"}, 1339 1339 data={ 1340 1340 "day": "20250103", ··· 1345 1345 assert resp.status_code == 200 1346 1346 1347 1347 # Check stats - no duplicates_rejected yet 1348 - resp = env.client.get("/app/remote/api/list") 1348 + resp = env.client.get("/app/observer/api/list") 1349 1349 stats = resp.get_json()[0]["stats"] 1350 1350 assert stats.get("duplicates_rejected", 0) == 0 1351 1351 1352 1352 # Submit duplicate 1353 1353 resp = env.client.post( 1354 - "/app/remote/ingest", 1354 + "/app/observer/ingest", 1355 1355 headers={"Authorization": f"Bearer {key}"}, 1356 1356 data={ 1357 1357 "day": "20250103", ··· 1363 1363 assert resp.get_json()["status"] == "duplicate" 1364 1364 1365 1365 # Check stats - should have 1 duplicate rejected 1366 - resp = env.client.get("/app/remote/api/list") 1366 + resp = env.client.get("/app/observer/api/list") 1367 1367 stats = resp.get_json()[0]["stats"] 1368 1368 assert stats["duplicates_rejected"] == 1 1369 1369 1370 1370 1371 - def test_ingest_partial_duplicate_creates_new_segment(remote_env): 1371 + def test_ingest_partial_duplicate_creates_new_segment(observer_env): 1372 1372 """Test that partial duplicate (some files match) creates new segment.""" 1373 - env = remote_env() 1373 + env = observer_env() 1374 1374 1375 - # Create a remote 1375 + # Create a observer 1376 1376 resp = env.client.post( 1377 - "/app/remote/api/create", 1377 + "/app/observer/api/create", 1378 1378 json={"name": "partial-dup-test"}, 1379 1379 content_type="application/json", 1380 1380 ) ··· 1386 1386 1387 1387 # First upload with audio and screen 1388 1388 resp = env.client.post( 1389 - "/app/remote/ingest", 1389 + "/app/observer/ingest", 1390 1390 headers={"Authorization": f"Bearer {key}"}, 1391 1391 data={ 1392 1392 "day": "20250103", ··· 1396 1396 ) 1397 1397 # Add files manually for multipart 1398 1398 resp = env.client.post( 1399 - "/app/remote/ingest", 1399 + "/app/observer/ingest", 1400 1400 headers={"Authorization": f"Bearer {key}"}, 1401 1401 data={ 1402 1402 "day": "20250103", ··· 1414 1414 1415 1415 # Second upload with same audio but different screen 1416 1416 resp = env.client.post( 1417 - "/app/remote/ingest", 1417 + "/app/observer/ingest", 1418 1418 headers={"Authorization": f"Bearer {key}"}, 1419 1419 data={ 1420 1420 "day": "20250103", ··· 1433 1433 assert second_data["segment"] != first_segment 1434 1434 1435 1435 1436 - def test_ingest_partial_match_logged_in_history(remote_env): 1436 + def test_ingest_partial_match_logged_in_history(observer_env): 1437 1437 """Test that partial SHA256 matches are logged in history record.""" 1438 - env = remote_env() 1438 + env = observer_env() 1439 1439 1440 - # Create a remote 1440 + # Create a observer 1441 1441 resp = env.client.post( 1442 - "/app/remote/api/create", 1442 + "/app/observer/api/create", 1443 1443 json={"name": "partial-log-test"}, 1444 1444 content_type="application/json", 1445 1445 ) ··· 1451 1451 1452 1452 # First upload 1453 1453 resp = env.client.post( 1454 - "/app/remote/ingest", 1454 + "/app/observer/ingest", 1455 1455 headers={"Authorization": f"Bearer {key}"}, 1456 1456 data={ 1457 1457 "day": "20250103", ··· 1464 1464 # Second upload with same audio but new additional file 1465 1465 new_data = b"brand new file" 1466 1466 resp = env.client.post( 1467 - "/app/remote/ingest", 1467 + "/app/observer/ingest", 1468 1468 headers={"Authorization": f"Bearer {key}"}, 1469 1469 data={ 1470 1470 "day": "20250103", ··· 1481 1481 hist_path = ( 1482 1482 env.journal 1483 1483 / "apps" 1484 - / "remote" 1485 - / "remotes" 1484 + / "observer" 1485 + / "observers" 1486 1486 / key_prefix 1487 1487 / "hist" 1488 1488 / "20250103.jsonl" ··· 1499 1499 assert len(upload_records[1]["partial_match_sha256s"]) == 1 1500 1500 1501 1501 1502 - def test_ingest_returns_collision_status_when_adjusted(remote_env): 1502 + def test_ingest_returns_collision_status_when_adjusted(observer_env): 1503 1503 """Test that collision resolution returns status='collision'.""" 1504 - env = remote_env() 1504 + env = observer_env() 1505 1505 1506 - # Create a remote 1506 + # Create a observer 1507 1507 resp = env.client.post( 1508 - "/app/remote/api/create", 1508 + "/app/observer/api/create", 1509 1509 json={"name": "collision-status-test"}, 1510 1510 content_type="application/json", 1511 1511 ) ··· 1521 1521 # Upload - will need collision resolution 1522 1522 test_data = b"new content" 1523 1523 resp = env.client.post( 1524 - "/app/remote/ingest", 1524 + "/app/observer/ingest", 1525 1525 headers={"Authorization": f"Bearer {key}"}, 1526 1526 data={ 1527 1527 "day": "20250103", ··· 1535 1535 assert data["segment"] != "120000_300" # Adjusted 1536 1536 1537 1537 1538 - def test_ingest_zero_byte_file_rejected(remote_env): 1538 + def test_ingest_zero_byte_file_rejected(observer_env): 1539 1539 """Test that uploading only 0-byte files returns 400.""" 1540 - env = remote_env() 1540 + env = observer_env() 1541 1541 1542 - # Create a remote 1542 + # Create a observer 1543 1543 resp = env.client.post( 1544 - "/app/remote/api/create", 1545 - json={"name": "test-remote"}, 1544 + "/app/observer/api/create", 1545 + json={"name": "test-observer"}, 1546 1546 content_type="application/json", 1547 1547 ) 1548 1548 key = resp.get_json()["key"] 1549 1549 1550 1550 # Upload a 0-byte file 1551 1551 resp = env.client.post( 1552 - "/app/remote/ingest", 1552 + "/app/observer/ingest", 1553 1553 headers={"Authorization": f"Bearer {key}"}, 1554 1554 data={ 1555 1555 "day": "20250103", ··· 1561 1561 assert "No valid files" in resp.get_json()["error"] 1562 1562 1563 1563 1564 - def test_ingest_mixed_zero_byte_files(remote_env): 1564 + def test_ingest_mixed_zero_byte_files(observer_env): 1565 1565 """Test that 0-byte files are skipped but valid files are accepted.""" 1566 - env = remote_env() 1566 + env = observer_env() 1567 1567 1568 - # Create a remote 1568 + # Create a observer 1569 1569 resp = env.client.post( 1570 - "/app/remote/api/create", 1571 - json={"name": "test-remote"}, 1570 + "/app/observer/api/create", 1571 + json={"name": "test-observer"}, 1572 1572 content_type="application/json", 1573 1573 ) 1574 1574 key = resp.get_json()["key"] ··· 1576 1576 # Upload one valid file and one 0-byte file 1577 1577 valid_data = b"real audio content" 1578 1578 resp = env.client.post( 1579 - "/app/remote/ingest", 1579 + "/app/observer/ingest", 1580 1580 headers={"Authorization": f"Bearer {key}"}, 1581 1581 data={ 1582 1582 "day": "20250103", ··· 1595 1595 1596 1596 # Verify only valid file was written 1597 1597 expected_file = ( 1598 - env.journal / "20250103" / "test-remote" / "120000_300" / "audio.flac" 1598 + env.journal / "20250103" / "test-observer" / "120000_300" / "audio.flac" 1599 1599 ) 1600 1600 assert expected_file.exists() 1601 1601 assert expected_file.read_bytes() == valid_data 1602 1602 1603 1603 1604 - def test_ingest_stream_qualifier_preserved(remote_env): 1604 + def test_ingest_stream_qualifier_preserved(observer_env): 1605 1605 """Regression: tmux observer must land in host.tmux, not host stream. 1606 1606 1607 1607 When a client registers as "fedora.tmux" and uploads with 1608 1608 meta={"stream": "fedora.tmux"}, the server was calling 1609 - stream_name(remote="fedora.tmux") which strips the qualifier via 1609 + stream_name(observer="fedora.tmux") which strips the qualifier via 1610 1610 _strip_hostname, collapsing both desktop and tmux observers into 1611 1611 the same "fedora" stream. The fix: trust meta["stream"] when present. 1612 1612 """ 1613 - env = remote_env() 1613 + env = observer_env() 1614 1614 1615 1615 # Register as the tmux observer would (name = stream name with qualifier) 1616 1616 resp = env.client.post( 1617 - "/app/remote/api/create", 1617 + "/app/observer/api/create", 1618 1618 json={"name": "fedora.tmux"}, 1619 1619 content_type="application/json", 1620 1620 ) ··· 1623 1623 test_data = b"tmux capture content" 1624 1624 meta = json.dumps({"host": "fedora", "platform": "linux", "stream": "fedora.tmux"}) 1625 1625 resp = env.client.post( 1626 - "/app/remote/ingest", 1626 + "/app/observer/ingest", 1627 1627 headers={"Authorization": f"Bearer {key}"}, 1628 1628 data={ 1629 1629 "day": "20250103",
+60 -60
apps/remote/tests/test_utils.py apps/observer/tests/test_utils.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Tests for remote app utilities.""" 4 + """Tests for observer app utilities.""" 5 5 6 6 from __future__ import annotations 7 7 ··· 9 9 10 10 import pytest 11 11 12 - from apps.remote.utils import ( 12 + from apps.observer.utils import ( 13 13 append_history_record, 14 - find_remote_by_name, 14 + find_observer_by_name, 15 15 find_segment_by_sha256, 16 16 get_hist_dir, 17 - get_remotes_dir, 17 + get_observers_dir, 18 18 increment_stat, 19 - list_remotes, 19 + list_observers, 20 20 load_history, 21 - load_remote, 22 - save_remote, 21 + load_observer, 22 + save_observer, 23 23 ) 24 24 25 25 ··· 33 33 monkeypatch.setenv("_SOLSTONE_JOURNAL_OVERRIDE", str(journal)) 34 34 monkeypatch.setattr(state, "journal_root", str(journal)) 35 35 36 - # Create remotes directory 37 - remotes_dir = journal / "apps" / "remote" / "remotes" 38 - remotes_dir.mkdir(parents=True) 36 + # Create observers directory 37 + observers_dir = journal / "apps" / "observer" / "observers" 38 + observers_dir.mkdir(parents=True) 39 39 40 40 class Env: 41 41 def __init__(self): 42 42 self.journal = journal 43 - self.remotes_dir = remotes_dir 43 + self.observers_dir = observers_dir 44 44 45 45 return Env() 46 46 47 47 48 - class TestRemoteStorage: 49 - """Tests for remote metadata storage.""" 48 + class TestObserverStorage: 49 + """Tests for observer metadata storage.""" 50 50 51 - def test_get_remotes_dir_creates_directory(self, storage_env): 52 - """get_remotes_dir creates and returns remotes directory.""" 53 - result = get_remotes_dir() 51 + def test_get_observers_dir_creates_directory(self, storage_env): 52 + """get_observers_dir creates and returns observers directory.""" 53 + result = get_observers_dir() 54 54 assert result.exists() 55 - assert result == storage_env.remotes_dir 55 + assert result == storage_env.observers_dir 56 56 57 - def test_save_and_load_remote(self, storage_env): 58 - """save_remote and load_remote work together.""" 59 - remote = { 57 + def test_save_and_load_observer(self, storage_env): 58 + """save_observer and load_observer work together.""" 59 + observer = { 60 60 "key": "testkey123456789", 61 - "name": "test-remote", 61 + "name": "test-observer", 62 62 "stats": {"segments_received": 0}, 63 63 } 64 64 65 - assert save_remote(remote) is True 65 + assert save_observer(observer) is True 66 66 67 - loaded = load_remote("testkey123456789") 67 + loaded = load_observer("testkey123456789") 68 68 assert loaded is not None 69 - assert loaded["name"] == "test-remote" 69 + assert loaded["name"] == "test-observer" 70 70 71 - def test_load_remote_wrong_key(self, storage_env): 72 - """load_remote returns None for wrong key.""" 73 - remote = { 71 + def test_load_observer_wrong_key(self, storage_env): 72 + """load_observer returns None for wrong key.""" 73 + observer = { 74 74 "key": "testkey123456789", 75 - "name": "test-remote", 75 + "name": "test-observer", 76 76 "stats": {}, 77 77 } 78 - save_remote(remote) 78 + save_observer(observer) 79 79 80 80 # Same prefix but different key 81 - result = load_remote("testkey1xxxxxxxx") 81 + result = load_observer("testkey1xxxxxxxx") 82 82 assert result is None 83 83 84 - def test_load_remote_not_found(self, storage_env): 85 - """load_remote returns None when remote doesn't exist.""" 86 - result = load_remote("nonexistent12345") 84 + def test_load_observer_not_found(self, storage_env): 85 + """load_observer returns None when observer doesn't exist.""" 86 + result = load_observer("nonexistent12345") 87 87 assert result is None 88 88 89 - def test_list_remotes_empty(self, storage_env): 90 - """list_remotes returns empty list when no remotes.""" 91 - result = list_remotes() 89 + def test_list_observers_empty(self, storage_env): 90 + """list_observers returns empty list when no observers.""" 91 + result = list_observers() 92 92 assert result == [] 93 93 94 - def test_list_remotes_returns_all(self, storage_env): 95 - """list_remotes returns all registered remotes.""" 94 + def test_list_observers_returns_all(self, storage_env): 95 + """list_observers returns all registered observers.""" 96 96 for i in range(3): 97 - save_remote( 97 + save_observer( 98 98 { 99 - "key": f"remote{i}0123456789", 100 - "name": f"remote-{i}", 99 + "key": f"obs{i:05d}123456789", 100 + "name": f"observer-{i}", 101 101 "created_at": 1000 + i, 102 102 "stats": {}, 103 103 } 104 104 ) 105 105 106 - result = list_remotes() 106 + result = list_observers() 107 107 assert len(result) == 3 108 108 # Sorted by created_at descending 109 - assert result[0]["name"] == "remote-2" 110 - assert result[1]["name"] == "remote-1" 111 - assert result[2]["name"] == "remote-0" 109 + assert result[0]["name"] == "observer-2" 110 + assert result[1]["name"] == "observer-1" 111 + assert result[2]["name"] == "observer-0" 112 112 113 - def test_find_remote_by_name(self, storage_env): 114 - """find_remote_by_name finds existing remote.""" 115 - save_remote( 113 + def test_find_observer_by_name(self, storage_env): 114 + """find_observer_by_name finds existing observer.""" 115 + save_observer( 116 116 { 117 117 "key": "findme123456789", 118 118 "name": "find-me", ··· 120 120 } 121 121 ) 122 122 123 - result = find_remote_by_name("find-me") 123 + result = find_observer_by_name("find-me") 124 124 assert result is not None 125 125 assert result["key"] == "findme123456789" 126 126 127 - def test_find_remote_by_name_not_found(self, storage_env): 128 - """find_remote_by_name returns None for unknown name.""" 129 - result = find_remote_by_name("unknown") 127 + def test_find_observer_by_name_not_found(self, storage_env): 128 + """find_observer_by_name returns None for unknown name.""" 129 + result = find_observer_by_name("unknown") 130 130 assert result is None 131 131 132 132 ··· 137 137 """get_hist_dir creates history directory.""" 138 138 result = get_hist_dir("testkey1") 139 139 assert result.exists() 140 - assert result == storage_env.remotes_dir / "testkey1" / "hist" 140 + assert result == storage_env.observers_dir / "testkey1" / "hist" 141 141 142 142 def test_get_hist_dir_no_create(self, storage_env): 143 143 """get_hist_dir with ensure_exists=False doesn't create.""" ··· 153 153 "testkey1", "20250103", {"type": "observed", "segment": "120000_300"} 154 154 ) 155 155 156 - hist_path = storage_env.remotes_dir / "testkey1" / "hist" / "20250103.jsonl" 156 + hist_path = storage_env.observers_dir / "testkey1" / "hist" / "20250103.jsonl" 157 157 assert hist_path.exists() 158 158 159 159 with open(hist_path) as f: ··· 184 184 185 185 def test_increment_stat_new_counter(self, storage_env): 186 186 """increment_stat creates new counter.""" 187 - save_remote( 187 + save_observer( 188 188 { 189 189 "key": "testkey123456789", 190 190 "name": "test", ··· 194 194 195 195 increment_stat("testkey1", "segments_observed") 196 196 197 - loaded = load_remote("testkey123456789") 197 + loaded = load_observer("testkey123456789") 198 198 assert loaded["stats"]["segments_observed"] == 1 199 199 200 200 def test_increment_stat_existing_counter(self, storage_env): 201 201 """increment_stat increments existing counter.""" 202 - save_remote( 202 + save_observer( 203 203 { 204 204 "key": "testkey123456789", 205 205 "name": "test", ··· 209 209 210 210 increment_stat("testkey1", "segments_observed") 211 211 212 - loaded = load_remote("testkey123456789") 212 + loaded = load_observer("testkey123456789") 213 213 assert loaded["stats"]["segments_observed"] == 6 214 214 215 - def test_increment_stat_missing_remote(self, storage_env): 216 - """increment_stat handles missing remote gracefully.""" 215 + def test_increment_stat_missing_observer(self, storage_env): 216 + """increment_stat handles missing observer gracefully.""" 217 217 # Should not raise 218 218 increment_stat("nonexistent", "segments_observed") 219 219
+53 -53
apps/remote/utils.py apps/observer/utils.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """Shared utilities for the remote app. 4 + """Shared utilities for the observer app. 5 5 6 - Provides common helpers for remote metadata management and sync history 6 + Provides common helpers for observer metadata management and sync history 7 7 that are used by both routes.py and events.py. 8 8 """ 9 9 ··· 19 19 logger = logging.getLogger(__name__) 20 20 21 21 22 - def get_remotes_dir() -> Path: 23 - """Get the remotes storage directory.""" 24 - return get_app_storage_path("remote", "remotes", ensure_exists=True) 22 + def get_observers_dir() -> Path: 23 + """Get the observers storage directory.""" 24 + return get_app_storage_path("observer", "observers", ensure_exists=True) 25 25 26 26 27 27 def get_hist_dir(key_prefix: str, ensure_exists: bool = True) -> Path: 28 - """Get the history directory for a remote. 28 + """Get the history directory for an observer. 29 29 30 30 Args: 31 - key_prefix: First 8 chars of remote key 31 + key_prefix: First 8 chars of observer key 32 32 ensure_exists: Create directory if it doesn't exist (default: True) 33 33 34 34 Returns: 35 - Path to apps/remote/remotes/<key_prefix>/hist/ 35 + Path to apps/observer/observers/<key_prefix>/hist/ 36 36 """ 37 37 return get_app_storage_path( 38 - "remote", "remotes", key_prefix, "hist", ensure_exists=ensure_exists 38 + "observer", "observers", key_prefix, "hist", ensure_exists=ensure_exists 39 39 ) 40 40 41 41 42 - def load_remote(key: str) -> dict | None: 43 - """Load remote metadata by key. 42 + def load_observer(key: str) -> dict | None: 43 + """Load observer metadata by key. 44 44 45 45 Args: 46 - key: Full remote authentication key 46 + key: Full observer authentication key 47 47 48 48 Returns: 49 - Remote metadata dict if found and key matches, None otherwise 49 + Observer metadata dict if found and key matches, None otherwise 50 50 """ 51 - remotes_dir = get_remotes_dir() 52 - remote_path = remotes_dir / f"{key[:8]}.json" 53 - if not remote_path.exists(): 51 + observers_dir = get_observers_dir() 52 + observer_path = observers_dir / f"{key[:8]}.json" 53 + if not observer_path.exists(): 54 54 return None 55 55 try: 56 - with open(remote_path) as f: 56 + with open(observer_path) as f: 57 57 data = json.load(f) 58 58 # Verify full key matches 59 59 if data.get("key") != key: ··· 63 63 return None 64 64 65 65 66 - def save_remote(data: dict) -> bool: 67 - """Save remote metadata. 66 + def save_observer(data: dict) -> bool: 67 + """Save observer metadata. 68 68 69 69 Args: 70 - data: Remote metadata dict (must contain 'key' field) 70 + data: Observer metadata dict (must contain 'key' field) 71 71 72 72 Returns: 73 73 True if saved successfully, False otherwise ··· 75 75 key = data.get("key") 76 76 if not key: 77 77 return False 78 - remotes_dir = get_remotes_dir() 79 - remote_path = remotes_dir / f"{key[:8]}.json" 78 + observers_dir = get_observers_dir() 79 + observer_path = observers_dir / f"{key[:8]}.json" 80 80 try: 81 - with open(remote_path, "w") as f: 81 + with open(observer_path, "w") as f: 82 82 json.dump(data, f, indent=2) 83 - os.chmod(remote_path, 0o600) 83 + os.chmod(observer_path, 0o600) 84 84 return True 85 85 except OSError: 86 86 return False 87 87 88 88 89 - def list_remotes() -> list[dict]: 90 - """List all registered remotes. 89 + def list_observers() -> list[dict]: 90 + """List all registered observers. 91 91 92 92 Returns: 93 - List of remote metadata dicts, sorted by created_at descending 93 + List of observer metadata dicts, sorted by created_at descending 94 94 """ 95 - remotes_dir = get_remotes_dir() 96 - remotes = [] 97 - for remote_path in remotes_dir.glob("*.json"): 95 + observers_dir = get_observers_dir() 96 + observers = [] 97 + for observer_path in observers_dir.glob("*.json"): 98 98 try: 99 - with open(remote_path) as f: 99 + with open(observer_path) as f: 100 100 data = json.load(f) 101 - remotes.append(data) 101 + observers.append(data) 102 102 except (json.JSONDecodeError, OSError): 103 103 continue 104 - remotes.sort(key=lambda x: x.get("created_at", 0), reverse=True) 105 - return remotes 104 + observers.sort(key=lambda x: x.get("created_at", 0), reverse=True) 105 + return observers 106 106 107 107 108 - def find_remote_by_name(name: str) -> dict | None: 109 - """Find remote metadata by name. 108 + def find_observer_by_name(name: str) -> dict | None: 109 + """Find observer metadata by name. 110 110 111 111 Args: 112 - name: Remote name to search for 112 + name: Observer name to search for 113 113 114 114 Returns: 115 - Remote metadata dict if found, None otherwise 115 + Observer metadata dict if found, None otherwise 116 116 """ 117 - for remote in list_remotes(): 118 - if remote.get("name") == name: 119 - return remote 117 + for observer in list_observers(): 118 + if observer.get("name") == name: 119 + return observer 120 120 return None 121 121 122 122 ··· 124 124 """Append a record to the sync history file. 125 125 126 126 Args: 127 - key_prefix: First 8 chars of remote key 127 + key_prefix: First 8 chars of observer key 128 128 day: Day string (YYYYMMDD) 129 129 record: Record to append (will be JSON-serialized) 130 130 """ ··· 135 135 136 136 137 137 def load_history(key_prefix: str, day: str) -> list[dict]: 138 - """Load sync history for a remote on a given day. 138 + """Load sync history for an observer on a given day. 139 139 140 140 Args: 141 - key_prefix: First 8 chars of remote key 141 + key_prefix: First 8 chars of observer key 142 142 day: Day string (YYYYMMDD) 143 143 144 144 Returns: ··· 162 162 163 163 164 164 def increment_stat(key_prefix: str, stat_name: str) -> None: 165 - """Increment a stat counter for a remote. 165 + """Increment a stat counter for an observer. 166 166 167 167 Args: 168 - key_prefix: First 8 chars of remote key 168 + key_prefix: First 8 chars of observer key 169 169 stat_name: Name of the stat to increment (e.g., 'segments_observed') 170 170 """ 171 - remotes_dir = get_remotes_dir() 172 - remote_path = remotes_dir / f"{key_prefix}.json" 173 - if not remote_path.exists(): 171 + observers_dir = get_observers_dir() 172 + observer_path = observers_dir / f"{key_prefix}.json" 173 + if not observer_path.exists(): 174 174 return 175 175 176 176 try: 177 - with open(remote_path) as f: 177 + with open(observer_path) as f: 178 178 data = json.load(f) 179 179 180 180 data["stats"][stat_name] = data["stats"].get(stat_name, 0) + 1 181 181 182 - with open(remote_path, "w") as f: 182 + with open(observer_path, "w") as f: 183 183 json.dump(data, f, indent=2) 184 - os.chmod(remote_path, 0o600) 184 + os.chmod(observer_path, 0o600) 185 185 except (json.JSONDecodeError, OSError, KeyError) as e: 186 186 logger.warning(f"Failed to update {stat_name} for {key_prefix}: {e}") 187 187 ··· 195 195 all provided SHA256 hashes match existing files. 196 196 197 197 Args: 198 - key_prefix: First 8 chars of remote key 198 + key_prefix: First 8 chars of observer key 199 199 day: Day string (YYYYMMDD) 200 200 file_sha256s: Set of SHA256 hashes to match 201 201
+78 -78
apps/remote/workspace.html apps/observer/workspace.html
··· 1 1 <style> 2 - .remote-card { 2 + .observer-card { 3 3 background: #f8f9fa; 4 4 border: 1px solid #dee2e6; 5 5 border-radius: 8px; 6 6 padding: 1em; 7 7 margin-bottom: 1em; 8 8 } 9 - .remote-card.disconnected { 9 + .observer-card.disconnected { 10 10 opacity: 0.7; 11 11 } 12 - .remote-card.revoked { 12 + .observer-card.revoked { 13 13 opacity: 0.5; 14 14 background: #e9ecef; 15 15 } 16 - .remote-header { 16 + .observer-header { 17 17 display: flex; 18 18 justify-content: space-between; 19 19 align-items: center; 20 20 margin-bottom: 0.5em; 21 21 } 22 - .remote-name { 22 + .observer-name { 23 23 font-weight: bold; 24 24 font-size: 1.1em; 25 25 } 26 - .remote-status { 26 + .observer-status { 27 27 display: inline-flex; 28 28 align-items: center; 29 29 gap: 6px; ··· 31 31 padding: 2px 8px; 32 32 border-radius: 4px; 33 33 } 34 - .remote-status.connected { 34 + .observer-status.connected { 35 35 background: #d4edda; 36 36 color: #155724; 37 37 } 38 - .remote-status.connected::before { 38 + .observer-status.connected::before { 39 39 content: ''; 40 40 width: 8px; 41 41 height: 8px; 42 42 border-radius: 50%; 43 43 background: #28a745; 44 44 } 45 - .remote-status.disconnected { 45 + .observer-status.disconnected { 46 46 background: #f8d7da; 47 47 color: #721c24; 48 48 } 49 - .remote-status.disconnected::before { 49 + .observer-status.disconnected::before { 50 50 content: ''; 51 51 width: 8px; 52 52 height: 8px; 53 53 border-radius: 50%; 54 54 background: #dc3545; 55 55 } 56 - .remote-status.revoked { 56 + .observer-status.revoked { 57 57 background: #e9ecef; 58 58 color: #6c757d; 59 59 } 60 - .remote-status.revoked::before { 60 + .observer-status.revoked::before { 61 61 content: ''; 62 62 width: 8px; 63 63 height: 8px; 64 64 border-radius: 50%; 65 65 background: #6c757d; 66 66 } 67 - .remote-stats { 67 + .observer-stats { 68 68 font-size: 0.9em; 69 69 color: #666; 70 70 margin-bottom: 0.5em; 71 71 } 72 - .remote-stats span { 72 + .observer-stats span { 73 73 margin-right: 1.5em; 74 74 } 75 - .remote-actions { 75 + .observer-actions { 76 76 display: flex; 77 77 gap: 0.5em; 78 78 } 79 - .remote-actions button { 79 + .observer-actions button { 80 80 padding: 4px 12px; 81 81 border: 1px solid #ccc; 82 82 border-radius: 4px; ··· 84 84 cursor: pointer; 85 85 font-size: 0.9em; 86 86 } 87 - .remote-actions button:hover { 87 + .observer-actions button:hover { 88 88 background: #f0f0f0; 89 89 } 90 - .remote-actions button.danger { 90 + .observer-actions button.danger { 91 91 color: #dc3545; 92 92 border-color: #dc3545; 93 93 } 94 - .remote-actions button.danger:hover { 94 + .observer-actions button.danger:hover { 95 95 background: #f8d7da; 96 96 } 97 97 98 - /* Add remote form */ 99 - .add-remote-form { 98 + /* Add observer form */ 99 + .add-observer-form { 100 100 display: flex; 101 101 gap: 0.5em; 102 102 margin-bottom: 1.5em; 103 103 } 104 - .add-remote-form input { 104 + .add-observer-form input { 105 105 flex: 1; 106 106 padding: 8px 12px; 107 107 border: 1px solid #ccc; 108 108 border-radius: 4px; 109 109 font-size: 1em; 110 110 } 111 - .add-remote-form button { 111 + .add-observer-form button { 112 112 padding: 8px 16px; 113 113 background: #007bff; 114 114 color: white; ··· 117 117 cursor: pointer; 118 118 font-weight: bold; 119 119 } 120 - .add-remote-form button:hover { 120 + .add-observer-form button:hover { 121 121 background: #0056b3; 122 122 } 123 - .add-remote-form button:disabled { 123 + .add-observer-form button:disabled { 124 124 background: #ccc; 125 125 cursor: not-allowed; 126 126 } 127 127 128 - /* Remote key modal */ 128 + /* Observer key modal */ 129 129 .modal { 130 130 display: none; 131 131 position: fixed; ··· 209 209 cursor: pointer; 210 210 } 211 211 212 - .no-remotes { 212 + .no-observers { 213 213 color: #666; 214 214 font-style: italic; 215 215 text-align: center; ··· 223 223 </style> 224 224 225 225 <div class="workspace-content"> 226 - <h2 class="section-title">Add Remote Observer</h2> 227 - <form class="add-remote-form" id="addRemoteForm"> 228 - <input type="text" id="remoteName" placeholder="Remote name (e.g., laptop, desktop)" required> 229 - <button type="submit">Add Remote</button> 226 + <h2 class="section-title">Add Observer Observer</h2> 227 + <form class="add-observer-form" id="addObserverForm"> 228 + <input type="text" id="observerName" placeholder="Observer name (e.g., laptop, desktop)" required> 229 + <button type="submit">Add Observer</button> 230 230 </form> 231 231 232 - <h2 class="section-title">Connected Remotes</h2> 233 - <div id="remotesList"> 234 - <div class="no-remotes">Loading...</div> 232 + <h2 class="section-title">Connected Observers</h2> 233 + <div id="observersList"> 234 + <div class="no-observers">Loading...</div> 235 235 </div> 236 236 </div> 237 237 238 - <!-- Remote Key Modal (for new remotes and viewing existing keys) --> 238 + <!-- Observer Key Modal (for new observers and viewing existing keys) --> 239 239 <div id="keyModal" class="modal"> 240 240 <div class="modal-content"> 241 241 <span class="modal-close" id="keyModalClose">&times;</span> 242 - <h3 id="keyModalTitle">Remote: <span id="modalRemoteName"></span></h3> 242 + <h3 id="keyModalTitle">Observer: <span id="modalObserverName"></span></h3> 243 243 <p>Use these credentials in your solstone app's service settings:</p> 244 244 <div class="credential-label">Server URL</div> 245 245 <div class="command-box"> ··· 261 261 </div> 262 262 263 263 <script> 264 - const remotesList = document.getElementById('remotesList'); 265 - const addRemoteForm = document.getElementById('addRemoteForm'); 266 - const remoteNameInput = document.getElementById('remoteName'); 264 + const observersList = document.getElementById('observersList'); 265 + const addObserverForm = document.getElementById('addObserverForm'); 266 + const observerNameInput = document.getElementById('observerName'); 267 267 const keyModal = document.getElementById('keyModal'); 268 - const modalRemoteName = document.getElementById('modalRemoteName'); 268 + const modalObserverName = document.getElementById('modalObserverName'); 269 269 const serverUrlText = document.getElementById('serverUrlText'); 270 270 const keyText = document.getElementById('keyText'); 271 271 const copyServerUrlBtn = document.getElementById('copyServerUrlBtn'); ··· 296 296 return (Date.now() - lastSeen) < 120000; 297 297 } 298 298 299 - async function loadRemotes() { 299 + async function loadObservers() { 300 300 try { 301 - const response = await fetch('/app/remote/api/list'); 302 - const remotes = await response.json(); 301 + const response = await fetch('/app/observer/api/list'); 302 + const observers = await response.json(); 303 303 304 - if (!remotes || remotes.length === 0) { 305 - remotesList.innerHTML = '<div class="no-remotes">No remotes registered yet. Add one above to get started.</div>'; 304 + if (!observers || observers.length === 0) { 305 + observersList.innerHTML = '<div class="no-observers">No observers registered yet. Add one above to get started.</div>'; 306 306 return; 307 307 } 308 308 309 309 let html = ''; 310 - for (const remote of remotes) { 311 - const isRevoked = remote.revoked; 310 + for (const observer of observers) { 311 + const isRevoked = observer.revoked; 312 312 let statusClass, statusText, cardClass; 313 313 314 314 if (isRevoked) { ··· 316 316 statusText = 'Revoked'; 317 317 cardClass = 'revoked'; 318 318 } else { 319 - const connected = isConnected(remote.last_seen); 319 + const connected = isConnected(observer.last_seen); 320 320 statusClass = connected ? 'connected' : 'disconnected'; 321 321 statusText = connected ? 'Connected' : 'Disconnected'; 322 322 cardClass = statusClass; 323 323 } 324 324 325 325 html += ` 326 - <div class="remote-card ${cardClass}" data-key="${remote.key_prefix}"> 327 - <div class="remote-header"> 328 - <span class="remote-name">${escapeHtml(remote.name)}</span> 329 - <span class="remote-status ${statusClass}">${statusText}</span> 326 + <div class="observer-card ${cardClass}" data-key="${observer.key_prefix}"> 327 + <div class="observer-header"> 328 + <span class="observer-name">${escapeHtml(observer.name)}</span> 329 + <span class="observer-status ${statusClass}">${statusText}</span> 330 330 </div> 331 - <div class="remote-stats"> 332 - <span>Last seen: ${formatTimeAgo(remote.last_seen)}</span> 333 - <span>Segments: ${remote.stats?.segments_received || 0}</span> 334 - <span>Data: ${formatBytes(remote.stats?.bytes_received || 0)}</span> 331 + <div class="observer-stats"> 332 + <span>Last seen: ${formatTimeAgo(observer.last_seen)}</span> 333 + <span>Segments: ${observer.stats?.segments_received || 0}</span> 334 + <span>Data: ${formatBytes(observer.stats?.bytes_received || 0)}</span> 335 335 </div> 336 - <div class="remote-actions"> 337 - ${isRevoked ? '' : `<button onclick="viewRemoteKey('${remote.key_prefix}', '${escapeHtml(remote.name)}')">View Key</button>`} 338 - ${isRevoked ? '' : `<button class="danger" onclick="revokeRemote('${remote.key_prefix}', '${escapeHtml(remote.name)}')">Revoke</button>`} 336 + <div class="observer-actions"> 337 + ${isRevoked ? '' : `<button onclick="viewObserverKey('${observer.key_prefix}', '${escapeHtml(observer.name)}')">View Key</button>`} 338 + ${isRevoked ? '' : `<button class="danger" onclick="revokeObserver('${observer.key_prefix}', '${escapeHtml(observer.name)}')">Revoke</button>`} 339 339 </div> 340 340 </div> 341 341 `; 342 342 } 343 - remotesList.innerHTML = html; 343 + observersList.innerHTML = html; 344 344 } catch (err) { 345 - remotesList.innerHTML = '<div class="no-remotes">Error loading remotes</div>'; 346 - console.error('Failed to load remotes:', err); 345 + observersList.innerHTML = '<div class="no-observers">Error loading observers</div>'; 346 + console.error('Failed to load observers:', err); 347 347 } 348 348 } 349 349 ··· 353 353 return div.innerHTML; 354 354 } 355 355 356 - async function revokeRemote(keyPrefix, name) { 357 - if (!confirm(`Revoke remote "${name}"? The observer will no longer be able to upload.`)) { 356 + async function revokeObserver(keyPrefix, name) { 357 + if (!confirm(`Revoke observer "${name}"? The observer will no longer be able to upload.`)) { 358 358 return; 359 359 } 360 360 361 361 try { 362 - const response = await fetch(`/app/remote/api/${keyPrefix}`, { 362 + const response = await fetch(`/app/observer/api/${keyPrefix}`, { 363 363 method: 'DELETE' 364 364 }); 365 365 ··· 368 368 throw new Error(data.error || 'Failed to revoke'); 369 369 } 370 370 371 - loadRemotes(); 371 + loadObservers(); 372 372 } catch (err) { 373 373 if (window.showError) showError(err.message); 374 374 } 375 375 } 376 376 377 - async function viewRemoteKey(keyPrefix, name) { 377 + async function viewObserverKey(keyPrefix, name) { 378 378 try { 379 - const response = await fetch(`/app/remote/api/${keyPrefix}/key`); 379 + const response = await fetch(`/app/observer/api/${keyPrefix}/key`); 380 380 const data = await response.json(); 381 381 382 382 if (!response.ok) { ··· 390 390 } 391 391 392 392 function showKeyModal(name, key) { 393 - modalRemoteName.textContent = name; 393 + modalObserverName.textContent = name; 394 394 serverUrlText.textContent = window.location.origin; 395 395 keyText.textContent = key; 396 396 keyModal.style.display = 'block'; 397 397 } 398 398 399 - addRemoteForm.onsubmit = async (e) => { 399 + addObserverForm.onsubmit = async (e) => { 400 400 e.preventDefault(); 401 - const name = remoteNameInput.value.trim(); 401 + const name = observerNameInput.value.trim(); 402 402 if (!name) return; 403 403 404 - const submitBtn = addRemoteForm.querySelector('button[type="submit"]'); 404 + const submitBtn = addObserverForm.querySelector('button[type="submit"]'); 405 405 submitBtn.disabled = true; 406 406 407 407 try { 408 - const response = await fetch('/app/remote/api/create', { 408 + const response = await fetch('/app/observer/api/create', { 409 409 method: 'POST', 410 410 headers: { 'Content-Type': 'application/json' }, 411 411 body: JSON.stringify({ name }) ··· 414 414 const data = await response.json(); 415 415 416 416 if (!response.ok) { 417 - throw new Error(data.error || 'Failed to create remote'); 417 + throw new Error(data.error || 'Failed to create observer'); 418 418 } 419 419 420 420 // Show modal with key 421 421 showKeyModal(name, data.key); 422 422 423 423 // Clear input and reload list 424 - remoteNameInput.value = ''; 425 - loadRemotes(); 424 + observerNameInput.value = ''; 425 + loadObservers(); 426 426 } catch (err) { 427 427 if (window.showError) showError(err.message); 428 428 } finally { ··· 462 462 setupCopyBtn(copyKeyBtn, keyText); 463 463 464 464 // Initial load 465 - loadRemotes(); 465 + loadObservers(); 466 466 467 467 // Refresh every 30 seconds to update connection status 468 - setInterval(loadRemotes, 30000); 468 + setInterval(loadObservers, 30000); 469 469 </script>
+2 -2
apps/utils.py
··· 111 111 112 112 When facet is provided, writes to facets/{facet}/logs/{day}.jsonl. 113 113 When facet is None, writes to config/actions/{day}.jsonl for journal-level 114 - actions (settings changes, remote observer management, etc.). 114 + actions (settings changes, observer management, etc.). 115 115 116 116 Args: 117 117 app: App name where action originated (e.g., "entities", "todos") ··· 131 131 132 132 # Journal-level action (no facet) 133 133 log_app_action( 134 - app="remote", 134 + app="observer", 135 135 facet=None, 136 136 action="observer_create", 137 137 params={"name": "laptop"},
+19 -19
convey/root.py
··· 70 70 "root.login", 71 71 "root.static", 72 72 "root.favicon", 73 - # Remote ingest endpoints use key-based auth, not session 74 - "app:remote.ingest_upload", 75 - "app:remote.ingest_event", 76 - "app:remote.ingest_segments", 73 + # Observer ingest endpoints use key-based auth, not session 74 + "app:observer.ingest_upload", 75 + "app:observer.ingest_event", 76 + "app:observer.ingest_segments", 77 77 }: 78 78 return None 79 79 ··· 178 178 if not _get_password_hash(): 179 179 return jsonify({"error": "Password required first"}), 403 180 180 181 - from apps.remote.utils import list_remotes 181 + from apps.observer.utils import list_observers 182 182 183 - remotes_list = [] 184 - for remote in list_remotes(): 185 - if remote.get("revoked", False): 183 + observers_list = [] 184 + for observer in list_observers(): 185 + if observer.get("revoked", False): 186 186 continue 187 - remotes_list.append( 187 + observers_list.append( 188 188 { 189 - "key_prefix": remote.get("key", "")[:8], 190 - "name": remote.get("name", ""), 191 - "created_at": remote.get("created_at", 0), 192 - "last_seen": remote.get("last_seen"), 193 - "last_segment": remote.get("last_segment"), 194 - "enabled": remote.get("enabled", True), 195 - "revoked": remote.get("revoked", False), 196 - "revoked_at": remote.get("revoked_at"), 197 - "stats": remote.get("stats", {}), 189 + "key_prefix": observer.get("key", "")[:8], 190 + "name": observer.get("name", ""), 191 + "created_at": observer.get("created_at", 0), 192 + "last_seen": observer.get("last_seen"), 193 + "last_segment": observer.get("last_segment"), 194 + "enabled": observer.get("enabled", True), 195 + "revoked": observer.get("revoked", False), 196 + "revoked_at": observer.get("revoked_at"), 197 + "stats": observer.get("stats", {}), 198 198 } 199 199 ) 200 - return jsonify(remotes_list) 200 + return jsonify(observers_list) 201 201 202 202 203 203 @bp.route("/init/finalize", methods=["POST"])
+2 -2
docs/APPS.md
··· 588 588 589 589 **Facet-scoped vs journal-level:** 590 590 - Pass a facet name for facet-specific actions (todos, entities, etc.) 591 - - Pass `facet=None` for journal-level actions (settings, remote observers, etc.) 591 + - Pass `facet=None` for journal-level actions (settings, observers, etc.) 592 592 593 593 Log after successful mutations, not attempts. 594 594 ··· 671 671 - If Callosum disconnected, message is dropped (with debug logging) 672 672 - Returns `True` if queued, `False` if bridge not started or queue full 673 673 674 - **Reference implementations:** `apps/import/routes.py`, `apps/remote/routes.py` 674 + **Reference implementations:** `apps/import/routes.py`, `apps/observer/routes.py` 675 675 676 676 --- 677 677
+6 -6
docs/CALLOSUM.md
··· 70 70 71 71 ### `observe` - Multimodal capture and processing 72 72 **Sources:** 73 - - Capture: standalone observer services (solstone-linux, solstone-tmux, solstone-macos) upload via remote ingest 73 + - Capture: standalone observer services (solstone-linux, solstone-tmux, solstone-macos) upload vian observer ingest 74 74 - Processing: `observe/sense.py`, `observe/describe.py`, `observe/transcribe/` 75 75 76 76 **Events:** ··· 83 83 | `transcribed` | transcribe | Audio transcription complete (includes VAD metadata) | 84 84 | `observed` | sense | All files for segment fully processed (may include errors) | 85 85 86 - **Common fields:** `day`, `segment`, `remote` (for remote uploads), `stream` (stream name, e.g., `"archon"`, `"import.apple"`) 86 + **Common fields:** `day`, `segment`, `observer` (for observer uploads), `stream` (stream name, e.g., `"archon"`, `"import.apple"`) 87 87 **`observing` event fields:** 88 - - `meta` (dict, optional): Metadata dict from remote observer. Contains `host`, `platform`, and any client-provided fields (e.g., `facet`, `setting`). Passed to handlers via `SEGMENT_META` env var and unrolled into JSONL metadata headers. 89 - - `stream` (str, optional): Stream name identifying the segment source. Set by observers, remote ingest, and importer. 88 + - `meta` (dict, optional): Metadata dict from observer. Contains `host`, `platform`, and any client-provided fields (e.g., `facet`, `setting`). Passed to handlers via `SEGMENT_META` env var and unrolled into JSONL metadata headers. 89 + - `stream` (str, optional): Stream name identifying the segment source. Set by observers, observer ingest, and importer. 90 90 91 91 **`observed` event fields:** 92 92 - `stream` (str, optional): Stream name, forwarded from the originating `observing` event. ··· 121 121 **`recorded`** - Emitted when a completed activity record is written to journal. Supervisor queues a per-activity dream task on receipt. 122 122 **Key fields:** `facet`, `day`, `segment`, `id`, `activity` (type), `segments` (full span), `level_avg`, `description`, `active_entities` 123 123 124 - ### `sync` - Remote segment synchronization 124 + ### `sync` - Observer segment synchronization 125 125 **Source:** `observe/sync.py` 126 126 **Events:** `status` 127 127 **Key fields:** `queue_size`, `segment`, `state`, `host`, `platform` 128 - **Purpose:** Track remote sync service status for segment uploads to central server 128 + **Purpose:** Track observer sync service status for segment uploads to central server 129 129 130 130 ### `notification` - In-app notification display 131 131 **Source:** `convey/static/websocket.js` (client-side listener; any service can emit)
+3 -3
docs/JOURNAL.md
··· 693 693 694 694 Action logs record an audit trail of owner-initiated actions and agent tool calls. There are two types: 695 695 696 - - **Journal-level logs** (`config/actions/`) – actions not tied to a specific facet (settings changes, remote observer management) 696 + - **Journal-level logs** (`config/actions/`) – actions not tied to a specific facet (settings changes, observer management) 697 697 - **Facet-scoped logs** (`facets/{facet}/logs/`) – actions within a specific facet (todos, entities) 698 698 699 699 ### Journal Action Logs ··· 908 908 - `prev_segment` – segment key of the predecessor (null for first) 909 909 - `seq` – sequence number within the stream 910 910 911 - Stream names follow the convention: `{hostname}` for local observers, `{remote_name}` for remotes, `import.{type}` for imports (e.g., `import.apple`, `import.text`). Global stream state is tracked in the top-level `streams/` directory as `{name}.json` files. 911 + Stream names follow the convention: `{hostname}` for local observers, `{observer_name}` for observers, `import.{type}` for imports (e.g., `import.apple`, `import.text`). Global stream state is tracked in the top-level `streams/` directory as `{name}.json` files. 912 912 913 913 Pre-stream segments (created before stream identity was added) have no `stream.json` and are handled gracefully as `None` throughout the pipeline. 914 914 ··· 964 964 - `model` – model used for transcription (e.g., "medium.en", "revai-fusion") 965 965 - `device` – device used for inference (e.g., "cuda", "cpu", "cloud") 966 966 - `compute_type` – compute precision used (e.g., "float16", "int8", "api") 967 - - `remote` – remote name if transcribed from a remote source (optional) 967 + - `observer` – observer name if transcribed from an observer source (optional) 968 968 - `imported` – object with import metadata for external files (optional): 969 969 - `id` – unique import identifier 970 970 - `facet` – facet name for entity extraction
+8 -8
docs/OBSERVE.md
··· 4 4 5 5 ## Observer Architecture 6 6 7 - Observers are independent capture agents that upload segments to solstone via the HTTP ingest API (`/app/remote/ingest/<key>`). Each observer runs as its own process with its own lifecycle — solstone core is the journal + processing engine. 7 + Observers are independent capture agents that upload segments to solstone via the HTTP ingest API (`/app/observer/ingest/<key>`). Each observer runs as its own process with its own lifecycle — solstone core is the journal + processing engine. 8 8 9 9 | Observer | What it captures | Repo | Runs as | 10 10 |----------|-----------------|------|---------| ··· 16 16 17 17 ```bash 18 18 # List all registered observers 19 - sol remote list 19 + sol observer list 20 20 21 21 # Register a new observer 22 - sol remote create <name> 22 + sol observer create <name> 23 23 24 24 # Check observer status 25 - sol remote status <name> 25 + sol observer status <name> 26 26 27 27 # Rename an observer 28 - sol remote rename <old> <new> 28 + sol observer rename <old> <new> 29 29 30 30 # Revoke an observer's key 31 - sol remote revoke <name> 31 + sol observer revoke <name> 32 32 ``` 33 33 34 34 ## Commands ··· 46 46 ``` 47 47 Observers (standalone or built-in) 48 48 ↓ HTTP multipart upload 49 - Remote Ingest API (/app/remote/ingest/<key>) 49 + Observer Ingest API (/app/observer/ingest/<key>) 50 50 51 51 Raw media files (*.flac, *.webm, tmux_*.jsonl) 52 52 ··· 79 79 - **linux/observer.py** — Linux capture: audio + screencast + activity detection 80 80 - **linux/screencast.py** — XDG Portal screencast with PipeWire + GStreamer 81 81 - **gnome/activity.py** — GNOME-specific activity detection (idle, lock, power save) 82 - - **remote_client.py** — HTTP upload client for observer → server communication 82 + - **observer_client.py** — HTTP upload client for observer → server communication 83 83 - **sense.py** — File watcher that dispatches transcription and description jobs 84 84 - **transcribe.py** — Audio transcription with faster-whisper and sentence-level embeddings 85 85 - **describe.py** — Vision analysis with Gemini, category-based prompts
+1 -1
docs/SOLCLI.md
··· 295 295 |-------|----------| 296 296 | Think (processing) | `import`, `dream`, `planner`, `indexer`, `supervisor`, `schedule`, `top`, `health`, `callosum`, `notify`, `heartbeat` | 297 297 | Service | `service` (+ aliases `up`, `down`, `start`) | 298 - | Observe (capture) | `transcribe`, `describe`, `sense`, `sync`, `transfer`, `remote` | 298 + | Observe (capture) | `transcribe`, `describe`, `sense`, `sync`, `transfer`, `observer` | 299 299 | Talent (AI agents) | `agents`, `cortex`, `talent`, `call`, `engage` | 300 300 | Convey (web UI) | `convey`, `restart-convey`, `screenshot`, `maint` | 301 301 | Specialized | `config`, `streams`, `journal-stats`, `formatter`, `detect-created` |
+1 -1
docs/THINK.md
··· 18 18 `--length` to limit the report to a specific time range. See `sol call transcripts --help` for additional commands. 19 19 - `sol dream` runs generators and agents for a single day via Cortex. 20 20 - `sol agents` is the unified CLI for tool agents and generators (spawned by Cortex, NDJSON protocol). 21 - - `sol supervisor` monitors observation heartbeats. Use `--no-observers` to disable local capture (sense still runs for remote uploads and imports). 21 + - `sol supervisor` monitors observation heartbeats. Use `--no-observers` to disable local capture (sense still runs for observer uploads and imports). 22 22 - `sol cortex` starts a Callosum-based service for managing AI agent instances and generators. 23 23 - `sol talent` lists available agents and generators with their configuration. Use `sol talent show <name>` to see details, and `sol talent show <name> --prompt` to see the fully composed prompt that would be sent to the LLM. 24 24
+7 -7
observe/describe.py
··· 438 438 # Files are in segment directories, filename is simple (e.g., center_DP-3_screen.webm) 439 439 metadata = {"raw": self.video_path.name} 440 440 441 - # Add remote origin if set (from sense.py for remote observer uploads) 442 - remote = os.getenv("REMOTE_NAME") 443 - if remote: 444 - metadata["remote"] = remote 441 + # Add observer origin if set (from sense.py for observer uploads) 442 + observer = os.getenv("OBSERVER_NAME") 443 + if observer: 444 + metadata["observer"] = observer 445 445 446 446 # Add segment metadata (from sense.py via SEGMENT_META env var) 447 447 segment_meta_str = os.getenv("SEGMENT_META") ··· 971 971 event_fields["day"] = day 972 972 if segment: 973 973 event_fields["segment"] = segment 974 - remote = os.getenv("REMOTE_NAME") 975 - if remote: 976 - event_fields["remote"] = remote 974 + observer = os.getenv("OBSERVER_NAME") 975 + if observer: 976 + event_fields["observer"] = observer 977 977 callosum_send("observe", "described", **event_fields) 978 978 except Exception as e: 979 979 logger.error(f"Failed to process {video_path}: {e}", exc_info=True)
+112 -112
observe/remote_cli.py observe/observer_cli.py
··· 1 1 # SPDX-License-Identifier: AGPL-3.0-only 2 2 # Copyright (c) 2026 sol pbc 3 3 4 - """CLI for remote observer management. 4 + """CLI for observer management. 5 5 6 6 Provides commands for creating, listing, revoking, and checking status 7 - of remote observer registrations. Operates directly on the journal 7 + of observer registrations. Operates directly on the journal 8 8 filesystem — no dependency on the Convey web server. 9 9 10 10 Usage: 11 - sol remote create <name> Create a new remote observer 12 - sol remote list List all registered remotes 13 - sol remote revoke <name-or-prefix> Revoke a remote registration 14 - sol remote status [name-or-prefix] Show remote status details 11 + sol observer create <name> Create a new observer 12 + sol observer list List all registered observers 13 + sol observer revoke <name-or-prefix> Revoke an observer registration 14 + sol observer status [name-or-prefix] Show observer status details 15 15 """ 16 16 17 17 from __future__ import annotations ··· 24 24 import secrets 25 25 import sys 26 26 27 - from apps.remote.utils import ( 28 - find_remote_by_name, 27 + from apps.observer.utils import ( 28 + find_observer_by_name, 29 29 get_hist_dir, 30 - get_remotes_dir, 31 - list_remotes, 30 + get_observers_dir, 31 + list_observers, 32 32 load_history, 33 - save_remote, 33 + save_observer, 34 34 ) 35 35 from apps.utils import log_app_action 36 36 from think.utils import now_ms, setup_cli ··· 45 45 46 46 47 47 def _generate_key() -> str: 48 - """Generate a URL-safe key for remote authentication.""" 48 + """Generate a URL-safe key for observer authentication.""" 49 49 return base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES)).decode().rstrip("=") 50 50 51 51 52 - def _find_remote(identifier: str) -> dict | None: 53 - """Find a remote by name or key prefix.""" 52 + def _find_observer(identifier: str) -> dict | None: 53 + """Find an observer by name or key prefix.""" 54 54 # Try name first 55 - remote = find_remote_by_name(identifier) 56 - if remote: 57 - return remote 55 + observer = find_observer_by_name(identifier) 56 + if observer: 57 + return observer 58 58 59 59 # Try key prefix (file is named <prefix>.json) 60 - remotes_dir = get_remotes_dir() 61 - remote_path = remotes_dir / f"{identifier}.json" 62 - if remote_path.exists(): 60 + observers_dir = get_observers_dir() 61 + observer_path = observers_dir / f"{identifier}.json" 62 + if observer_path.exists(): 63 63 try: 64 - with open(remote_path) as f: 64 + with open(observer_path) as f: 65 65 return json.load(f) 66 66 except (json.JSONDecodeError, OSError): 67 67 pass ··· 69 69 return None 70 70 71 71 72 - def _status_label(remote: dict) -> str: 72 + def _status_label(observer: dict) -> str: 73 73 """Get human-readable connection status.""" 74 - if remote.get("revoked", False): 74 + if observer.get("revoked", False): 75 75 return "revoked" 76 - last_seen = remote.get("last_seen") 76 + last_seen = observer.get("last_seen") 77 77 if last_seen is None: 78 78 return "disconnected" 79 79 if now_ms() - last_seen < CONNECTED_THRESHOLD_MS: ··· 104 104 105 105 106 106 def cmd_create(args: argparse.Namespace) -> int: 107 - """Create a new remote observer registration.""" 107 + """Create a new observer registration.""" 108 108 name = args.name 109 109 110 - if find_remote_by_name(name): 111 - print(f"Error: remote '{name}' already exists", file=sys.stderr) 110 + if find_observer_by_name(name): 111 + print(f"Error: observer '{name}' already exists", file=sys.stderr) 112 112 return 1 113 113 114 114 key = _generate_key() 115 - remote_data = { 115 + observer_data = { 116 116 "key": key, 117 117 "name": name, 118 118 "created_at": now_ms(), ··· 125 125 }, 126 126 } 127 127 128 - if not save_remote(remote_data): 129 - print("Error: failed to save remote", file=sys.stderr) 128 + if not save_observer(observer_data): 129 + print("Error: failed to save observer", file=sys.stderr) 130 130 return 1 131 131 132 132 log_app_action( 133 - app="remote", 133 + app="observer", 134 134 facet=None, 135 135 action="observer_create", 136 136 params={"name": name, "key_prefix": key[:8]}, ··· 140 140 print(json.dumps({"name": name, "key": key, "prefix": key[:8]})) 141 141 return 0 142 142 143 - print("Remote observer created:") 143 + print("Observer created:") 144 144 print(f" Name: {name}") 145 145 print(f" Prefix: {key[:8]}") 146 146 print(" server url: (set during server configuration)") ··· 149 149 150 150 151 151 def cmd_list(args: argparse.Namespace) -> int: 152 - """List all registered remotes.""" 153 - remotes = list_remotes() 152 + """List all registered observers.""" 153 + observers = list_observers() 154 154 155 155 if args.json_output: 156 156 result = [] 157 - for r in remotes: 157 + for r in observers: 158 158 stats = r.get("stats", {}) 159 159 result.append( 160 160 { ··· 169 169 print(json.dumps(result)) 170 170 return 0 171 171 172 - if not remotes: 173 - print("No remotes registered.") 172 + if not observers: 173 + print("No observers registered.") 174 174 return 0 175 175 176 176 print( ··· 179 179 ) 180 180 print("-" * 86) 181 181 182 - for r in remotes: 182 + for r in observers: 183 183 name = r.get("name", "") 184 184 prefix = r.get("key", "")[:8] 185 185 status = _status_label(r) ··· 196 196 197 197 198 198 def cmd_revoke(args: argparse.Namespace) -> int: 199 - """Revoke a remote registration (soft-delete).""" 199 + """Revoke an observer registration (soft-delete).""" 200 200 identifier = args.identifier 201 201 202 - remote = _find_remote(identifier) 203 - if not remote: 204 - print(f"Error: remote '{identifier}' not found", file=sys.stderr) 202 + observer = _find_observer(identifier) 203 + if not observer: 204 + print(f"Error: observer '{identifier}' not found", file=sys.stderr) 205 205 return 1 206 206 207 - if remote.get("revoked", False): 208 - print(f"Remote '{remote.get('name')}' is already revoked.", file=sys.stderr) 207 + if observer.get("revoked", False): 208 + print(f"Observer '{observer.get('name')}' is already revoked.", file=sys.stderr) 209 209 return 1 210 210 211 - name = remote.get("name", "") 212 - key_prefix = remote.get("key", "")[:8] 211 + name = observer.get("name", "") 212 + key_prefix = observer.get("key", "")[:8] 213 213 214 - remote["revoked"] = True 215 - remote["revoked_at"] = now_ms() 214 + observer["revoked"] = True 215 + observer["revoked_at"] = now_ms() 216 216 217 - if not save_remote(remote): 218 - print("Error: failed to save remote", file=sys.stderr) 217 + if not save_observer(observer): 218 + print("Error: failed to save observer", file=sys.stderr) 219 219 return 1 220 220 221 221 log_app_action( 222 - app="remote", 222 + app="observer", 223 223 facet=None, 224 224 action="observer_revoke", 225 225 params={"name": name, "key_prefix": key_prefix}, ··· 229 229 print(json.dumps({"name": name, "prefix": key_prefix, "revoked": True})) 230 230 return 0 231 231 232 - print(f"Revoked remote '{name}' ({key_prefix})") 232 + print(f"Revoked observer '{name}' ({key_prefix})") 233 233 return 0 234 234 235 235 236 236 def cmd_rename(args: argparse.Namespace) -> int: 237 - """Rename a remote observer (affects future stream names).""" 237 + """Rename an observer (affects future stream names).""" 238 238 identifier = args.identifier 239 239 new_name = args.new_name 240 240 241 - remote = _find_remote(identifier) 242 - if not remote: 243 - print(f"Error: remote '{identifier}' not found", file=sys.stderr) 241 + observer = _find_observer(identifier) 242 + if not observer: 243 + print(f"Error: observer '{identifier}' not found", file=sys.stderr) 244 244 return 1 245 245 246 246 # Check new name isn't taken 247 - existing = find_remote_by_name(new_name) 248 - if existing and existing.get("key") != remote.get("key"): 249 - print(f"Error: remote '{new_name}' already exists", file=sys.stderr) 247 + existing = find_observer_by_name(new_name) 248 + if existing and existing.get("key") != observer.get("key"): 249 + print(f"Error: observer '{new_name}' already exists", file=sys.stderr) 250 250 return 1 251 251 252 - old_name = remote.get("name", "") 252 + old_name = observer.get("name", "") 253 253 if old_name == new_name: 254 - print(f"Remote is already named '{new_name}'.", file=sys.stderr) 254 + print(f"Observer is already named '{new_name}'.", file=sys.stderr) 255 255 return 1 256 256 257 - key_prefix = remote.get("key", "")[:8] 258 - remote["name"] = new_name 257 + key_prefix = observer.get("key", "")[:8] 258 + observer["name"] = new_name 259 259 260 - if not save_remote(remote): 261 - print("Error: failed to save remote", file=sys.stderr) 260 + if not save_observer(observer): 261 + print("Error: failed to save observer", file=sys.stderr) 262 262 return 1 263 263 264 264 log_app_action( 265 - app="remote", 265 + app="observer", 266 266 facet=None, 267 267 action="observer_rename", 268 268 params={"old_name": old_name, "new_name": new_name, "key_prefix": key_prefix}, ··· 276 276 ) 277 277 return 0 278 278 279 - print(f"Renamed remote '{old_name}' -> '{new_name}' ({key_prefix})") 279 + print(f"Renamed observer '{old_name}' -> '{new_name}' ({key_prefix})") 280 280 print(f" Future segments will use stream: {new_name}") 281 281 print(f" Existing segments remain under stream: {old_name}") 282 282 return 0 283 283 284 284 285 285 def cmd_status(args: argparse.Namespace) -> int: 286 - """Show remote status details.""" 286 + """Show observer status details.""" 287 287 if args.identifier: 288 288 return _status_single(args.identifier, json_output=args.json_output) 289 289 return _status_all(json_output=args.json_output) 290 290 291 291 292 292 def _status_single(identifier: str, json_output: bool = False) -> int: 293 - """Detailed status for a single remote.""" 294 - remote = _find_remote(identifier) 295 - if not remote: 296 - print(f"Error: remote '{identifier}' not found", file=sys.stderr) 293 + """Detailed status for a single observer.""" 294 + observer = _find_observer(identifier) 295 + if not observer: 296 + print(f"Error: observer '{identifier}' not found", file=sys.stderr) 297 297 return 1 298 298 299 - name = remote.get("name", "") 300 - key_prefix = remote.get("key", "")[:8] 301 - stats = remote.get("stats", {}) 299 + name = observer.get("name", "") 300 + key_prefix = observer.get("key", "")[:8] 301 + stats = observer.get("stats", {}) 302 302 303 303 if json_output: 304 304 print( ··· 306 306 { 307 307 "name": name, 308 308 "prefix": key_prefix, 309 - "status": _status_label(remote), 310 - "created_at": remote.get("created_at"), 311 - "last_seen": remote.get("last_seen"), 312 - "revoked": remote.get("revoked", False), 309 + "status": _status_label(observer), 310 + "created_at": observer.get("created_at"), 311 + "last_seen": observer.get("last_seen"), 312 + "revoked": observer.get("revoked", False), 313 313 "segments": stats.get("segments_received", 0), 314 314 "bytes": stats.get("bytes_received", 0), 315 315 } ··· 317 317 ) 318 318 return 0 319 319 320 - print(f"Remote: {name}") 320 + print(f"Observer: {name}") 321 321 print(f" Prefix: {key_prefix}") 322 - print(f" Status: {_status_label(remote)}") 323 - print(f" Created: {_fmt_time(remote.get('created_at'))}") 324 - print(f" Last seen: {_fmt_time(remote.get('last_seen'))}") 325 - if remote.get("revoked"): 326 - print(f" Revoked at: {_fmt_time(remote.get('revoked_at'))}") 322 + print(f" Status: {_status_label(observer)}") 323 + print(f" Created: {_fmt_time(observer.get('created_at'))}") 324 + print(f" Last seen: {_fmt_time(observer.get('last_seen'))}") 325 + if observer.get("revoked"): 326 + print(f" Revoked at: {_fmt_time(observer.get('revoked_at'))}") 327 327 print(f" Segments: {stats.get('segments_received', 0)}") 328 328 print(f" Bytes: {_fmt_bytes(stats.get('bytes_received', 0))}") 329 329 if stats.get("duplicates_rejected"): ··· 358 358 359 359 360 360 def _status_all(json_output: bool = False) -> int: 361 - """Health overview for all remotes.""" 362 - remotes = list_remotes() 361 + """Health overview for all observers.""" 362 + observers = list_observers() 363 363 364 - if not remotes and not json_output: 365 - print("No remotes registered.") 364 + if not observers and not json_output: 365 + print("No observers registered.") 366 366 return 0 367 367 368 - connected = sum(1 for r in remotes if _status_label(r) == "connected") 369 - disconnected = sum(1 for r in remotes if _status_label(r) == "disconnected") 370 - revoked = sum(1 for r in remotes if _status_label(r) == "revoked") 368 + connected = sum(1 for r in observers if _status_label(r) == "connected") 369 + disconnected = sum(1 for r in observers if _status_label(r) == "disconnected") 370 + revoked = sum(1 for r in observers if _status_label(r) == "revoked") 371 371 total_segments = sum( 372 - r.get("stats", {}).get("segments_received", 0) for r in remotes 372 + r.get("stats", {}).get("segments_received", 0) for r in observers 373 373 ) 374 - total_bytes = sum(r.get("stats", {}).get("bytes_received", 0) for r in remotes) 374 + total_bytes = sum(r.get("stats", {}).get("bytes_received", 0) for r in observers) 375 375 376 376 if json_output: 377 377 print( 378 378 json.dumps( 379 379 { 380 - "total": len(remotes), 380 + "total": len(observers), 381 381 "connected": connected, 382 382 "disconnected": disconnected, 383 383 "revoked": revoked, 384 384 "total_segments": total_segments, 385 385 "total_bytes": total_bytes, 386 - "remotes": [ 386 + "observers": [ 387 387 { 388 388 "name": r.get("name", ""), 389 389 "status": _status_label(r), 390 390 "last_seen": r.get("last_seen"), 391 391 } 392 - for r in remotes 392 + for r in observers 393 393 ], 394 394 } 395 395 ) 396 396 ) 397 397 return 0 398 398 399 - print(f"Remote observers: {len(remotes)} total") 399 + print(f"Observers: {len(observers)} total") 400 400 print(f" Connected: {connected}") 401 401 print(f" Disconnected: {disconnected}") 402 402 print(f" Revoked: {revoked}") ··· 405 405 406 406 print(f"\n{'Name':<20} {'Status':<14} {'Last Seen':<18}") 407 407 print("-" * 54) 408 - for r in remotes: 408 + for r in observers: 409 409 name = r.get("name", "") 410 410 status = _status_label(r) 411 411 last_seen = _fmt_time(r.get("last_seen")) ··· 418 418 419 419 420 420 def main() -> None: 421 - """Entry point for sol remote CLI.""" 421 + """Entry point for sol observer CLI.""" 422 422 parser = argparse.ArgumentParser( 423 - prog="sol remote", 424 - description="Manage remote observer registrations", 423 + prog="sol observer", 424 + description="Manage observer registrations", 425 425 ) 426 426 427 427 parser.add_argument( ··· 431 431 sub = parser.add_subparsers(dest="command") 432 432 433 433 # create 434 - p_create = sub.add_parser("create", help="Create a new remote observer") 435 - p_create.add_argument("name", help="Name for the remote observer") 434 + p_create = sub.add_parser("create", help="Create a new observer") 435 + p_create.add_argument("name", help="Name for the observer") 436 436 437 437 # list 438 - sub.add_parser("list", help="List all registered remotes") 438 + sub.add_parser("list", help="List all registered observers") 439 439 440 440 # rename 441 - p_rename = sub.add_parser("rename", help="Rename a remote (affects future streams)") 442 - p_rename.add_argument("identifier", help="Remote name or key prefix") 443 - p_rename.add_argument("new_name", help="New name for the remote") 441 + p_rename = sub.add_parser("rename", help="Rename an observer (affects future streams)") 442 + p_rename.add_argument("identifier", help="Observer name or key prefix") 443 + p_rename.add_argument("new_name", help="New name for the observer") 444 444 445 445 # revoke 446 - p_revoke = sub.add_parser("revoke", help="Revoke a remote registration") 447 - p_revoke.add_argument("identifier", help="Remote name or key prefix") 446 + p_revoke = sub.add_parser("revoke", help="Revoke an observer registration") 447 + p_revoke.add_argument("identifier", help="Observer name or key prefix") 448 448 449 449 # status 450 - p_status = sub.add_parser("status", help="Show remote status details") 450 + p_status = sub.add_parser("status", help="Show observer status details") 451 451 p_status.add_argument( 452 452 "identifier", 453 453 nargs="?", 454 454 default=None, 455 - help="Remote name or key prefix (omit for overview)", 455 + help="Observer name or key prefix (omit for overview)", 456 456 ) 457 457 458 458 args = setup_cli(parser)
+14 -14
observe/remote_client.py observe/observer_client.py
··· 45 45 def finalize_draft(draft_dir: str, segment_key: str) -> str | None: 46 46 """Rename a draft directory to its final segment name. 47 47 48 - Preserves captured data locally when remote upload fails, so the 48 + Preserves captured data locally when observer upload fails, so the 49 49 dream pipeline can process it later. 50 50 51 51 Args: ··· 66 66 67 67 68 68 class ObserverClient: 69 - """HTTP client for uploading observer segments to the remote ingest server.""" 69 + """HTTP client for uploading observer segments to the ingest server.""" 70 70 71 71 def __init__( 72 72 self, ··· 75 75 platform_name: str = PLATFORM, 76 76 ): 77 77 config = get_config() 78 - remote_cfg = config.get("observe", {}).get("remote", {}) 79 - self._url = remote_cfg.get("url", "").rstrip("/") 78 + observer_cfg = config.get("observe", {}).get("observer", {}) 79 + self._url = observer_cfg.get("url", "").rstrip("/") 80 80 if not self._url: 81 81 # Discover local convey port from health directory 82 82 port = read_service_port("convey") ··· 86 86 else: 87 87 logger.warning("No convey port found in health directory") 88 88 self._url = "" 89 - self._key = remote_cfg.get("key") 90 - self._auto_register = remote_cfg.get("auto_register", True) 91 - self._name = remote_cfg.get("name") or stream 89 + self._key = observer_cfg.get("key") 90 + self._auto_register = observer_cfg.get("auto_register", True) 91 + self._name = observer_cfg.get("name") or stream 92 92 self._stream = stream 93 93 self._host = host 94 94 self._platform = platform_name ··· 111 111 ) 112 112 return 113 113 114 - config.setdefault("observe", {}).setdefault("remote", {})["key"] = key 114 + config.setdefault("observe", {}).setdefault("observer", {})["key"] = key 115 115 116 116 with open(config_path, "w", encoding="utf-8") as f: 117 117 json.dump(config, f, indent=2) 118 118 f.write("\n") 119 119 os.chmod(config_path, 0o600) 120 120 121 - logger.info(f"Persisted remote key to {config_path}") 121 + logger.info(f"Persisted observer key to {config_path}") 122 122 123 123 def _ensure_registered(self) -> None: 124 124 if self._key: ··· 127 127 return 128 128 if not self._auto_register: 129 129 logger.error( 130 - "No remote key configured and auto_register disabled. " 131 - "Set observe.remote.key in journal config or enable auto_register." 130 + "No observer key configured and auto_register disabled. " 131 + "Set observe.observer.key in journal config or enable auto_register." 132 132 ) 133 133 return 134 134 135 - url = f"{self._url}/app/remote/api/create" 135 + url = f"{self._url}/app/observer/api/create" 136 136 for attempt, delay in enumerate(RETRY_BACKOFF): 137 137 try: 138 138 resp = self._session.post( ··· 177 177 if not self._key: 178 178 return UploadResult(False) 179 179 180 - url = f"{self._url}/app/remote/ingest/{self._key}" 180 + url = f"{self._url}/app/observer/ingest/{self._key}" 181 181 for attempt, delay in enumerate(RETRY_BACKOFF): 182 182 file_handles = [] 183 183 files_data = [] ··· 250 250 if not self._key: 251 251 return False 252 252 253 - url = f"{self._url}/app/remote/ingest/{self._key}/event" 253 + url = f"{self._url}/app/observer/ingest/{self._key}/event" 254 254 payload = {"tract": tract, "event": event, **fields} 255 255 try: 256 256 resp = self._session.post(url, json=payload, timeout=EVENT_TIMEOUT)
+28 -28
observe/sense.py
··· 32 32 class QueuedItem: 33 33 """Item in a handler queue with context for deferred processing.""" 34 34 35 - __slots__ = ("file_path", "queued_at", "remote", "meta") 35 + __slots__ = ("file_path", "queued_at", "observer", "meta") 36 36 37 37 def __init__( 38 38 self, 39 39 file_path: Path, 40 - remote: Optional[str] = None, 40 + observer: Optional[str] = None, 41 41 meta: Optional[Dict[str, Any]] = None, 42 42 ): 43 43 self.file_path = file_path 44 44 self.queued_at = time.time() 45 - self.remote = remote 45 + self.observer = observer 46 46 self.meta = meta 47 47 48 48 ··· 65 65 def enqueue( 66 66 self, 67 67 file_path: Path, 68 - remote: Optional[str] = None, 68 + observer: Optional[str] = None, 69 69 meta: Optional[Dict[str, Any]] = None, 70 70 ) -> bool: 71 71 """Add file to queue if not already present. Returns True if queued.""" 72 72 queued_paths = [item.file_path for item in self.queue] 73 73 if file_path not in queued_paths: 74 - self.queue.append(QueuedItem(file_path, remote, meta)) 74 + self.queue.append(QueuedItem(file_path, observer, meta)) 75 75 return True 76 76 return False 77 77 ··· 154 154 self.segment_day: Dict[str, str] = {} 155 155 # Track batch origin: {segment_key: True} for segments from batch mode 156 156 self.segment_batch: Dict[str, bool] = {} 157 - # Track remote origin: {segment_key: remote_name} for remote observer segments 158 - self.segment_remote: Dict[str, str] = {} 157 + # Track observer origin: {segment_key: observer_name} for observer segments 158 + self.segment_observer: Dict[str, str] = {} 159 159 # Track handler errors per segment: {segment_key: [error_strings]} 160 160 self.segment_errors: Dict[str, list[str]] = {} 161 161 # Track stream identity per segment: {segment_key: stream_name} ··· 202 202 day: Optional[str] = None, 203 203 batch: bool = False, 204 204 segment: Optional[str] = None, 205 - remote: Optional[str] = None, 205 + observer: Optional[str] = None, 206 206 meta: Optional[Dict[str, Any]] = None, 207 207 cpu_fallback: bool = False, 208 208 ): ··· 217 217 day: Day string (YYYYMMDD), extracted from path if not provided 218 218 batch: Whether this is from batch processing mode 219 219 segment: Segment key, extracted from path if not provided 220 - remote: Remote name for REMOTE_NAME env var 220 + observer: Observer name for OBSERVER_NAME env var 221 221 meta: Optional metadata dict (facet, setting, host, platform, etc.) 222 222 to pass to handlers via SEGMENT_META env var 223 223 cpu_fallback: If True, this is a retry after GPU failure (adds --cpu, ··· 257 257 # Check if this handler uses serialized execution 258 258 handler_queue = self.handler_queues.get(handler_name) 259 259 if handler_queue and not handler_queue.can_start(): 260 - if handler_queue.enqueue(file_path, remote=remote, meta=meta): 260 + if handler_queue.enqueue(file_path, observer=observer, meta=meta): 261 261 logger.info( 262 262 f"Queueing {file_path.name} for {handler_name} " 263 263 f"(queue size: {handler_queue.queue_size()})" ··· 283 283 event_fields["day"] = day 284 284 if segment: 285 285 event_fields["segment"] = segment 286 - if remote: 287 - event_fields["remote"] = remote 286 + if observer: 287 + event_fields["observer"] = observer 288 288 if segment and segment in self.segment_stream: 289 289 event_fields["stream"] = self.segment_stream[segment] 290 290 self.callosum.emit("observe", "detected", **event_fields) ··· 308 308 f"Spawning {handler_name}{fallback_note} for {file_path.name}: {' '.join(cmd)}" 309 309 ) 310 310 311 - # Build environment with segment and remote context for handlers 311 + # Build environment with segment and observer context for handlers 312 312 env = os.environ.copy() 313 313 if segment: 314 314 env["SOL_SEGMENT"] = segment 315 - if remote: 316 - env["REMOTE_NAME"] = remote 315 + if observer: 316 + env["OBSERVER_NAME"] = observer 317 317 if meta: 318 318 env["SEGMENT_META"] = json.dumps(meta) 319 319 ··· 458 458 item.file_path, 459 459 handler_name, 460 460 command, 461 - remote=item.remote, 461 + observer=item.observer, 462 462 meta=item.meta, 463 463 ) 464 464 ··· 474 474 duration = int(time.time() - self.segment_start_time[segment]) 475 475 day = self.segment_day.get(segment) 476 476 batch = self.segment_batch.get(segment, False) 477 - remote = self.segment_remote.get(segment) 477 + observer = self.segment_observer.get(segment) 478 478 errors = self.segment_errors.get(segment) 479 479 stream = self.segment_stream.get(segment) 480 480 ··· 486 486 } 487 487 if batch: 488 488 event_fields["batch"] = True 489 - if remote: 490 - event_fields["remote"] = remote 489 + if observer: 490 + event_fields["observer"] = observer 491 491 if stream: 492 492 event_fields["stream"] = stream 493 493 if errors: ··· 521 521 del self.segment_day[segment] 522 522 if segment in self.segment_batch: 523 523 del self.segment_batch[segment] 524 - if segment in self.segment_remote: 525 - del self.segment_remote[segment] 524 + if segment in self.segment_observer: 525 + del self.segment_observer[segment] 526 526 if segment in self.segment_stream: 527 527 del self.segment_stream[segment] 528 528 if segment in self.segment_errors: ··· 558 558 self, 559 559 file_path: Path, 560 560 segment: Optional[str] = None, 561 - remote: Optional[str] = None, 561 + observer: Optional[str] = None, 562 562 meta: Optional[Dict[str, Any]] = None, 563 563 ): 564 564 """Route file to appropriate handler. ··· 566 566 Args: 567 567 file_path: Path to the file to process 568 568 segment: Optional segment key for tracking 569 - remote: Optional remote name for REMOTE_NAME env var 569 + observer: Optional observer name for OBSERVER_NAME env var 570 570 meta: Optional metadata dict for SEGMENT_META env var 571 571 """ 572 572 if not file_path.exists(): ··· 581 581 handler_name, 582 582 command, 583 583 segment=segment, 584 - remote=remote, 584 + observer=observer, 585 585 meta=meta, 586 586 ) 587 587 ··· 597 597 day = message.get("day") 598 598 segment = message.get("segment") 599 599 files = message.get("files", []) 600 - remote = message.get("remote") # Optional: set for remote observer uploads 600 + observer = message.get("observer") # Optional: set for observer uploads 601 601 meta = message.get("meta") # Optional: metadata dict (facet, setting, etc.) 602 602 stream = message.get("stream") # Optional: stream identity from observer 603 603 ··· 634 634 self.segment_files[segment] = set() 635 635 self.segment_start_time[segment] = time.time() 636 636 self.segment_day[segment] = day 637 - if remote: 638 - self.segment_remote[segment] = remote 637 + if observer: 638 + self.segment_observer[segment] = observer 639 639 for file_path in file_paths: 640 640 # Only track files that will be processed (match a pattern) 641 641 if self._match_pattern(file_path): ··· 643 643 644 644 # Process each file (pass segment context for env vars) 645 645 for file_path in file_paths: 646 - self._handle_file(file_path, segment=segment, remote=remote, meta=meta) 646 + self._handle_file(file_path, segment=segment, observer=observer, meta=meta) 647 647 648 648 # If no files matched any handler patterns, emit observed immediately 649 649 # (e.g., tmux-only segments with just .jsonl files)
+14 -14
observe/transcribe/main.py
··· 170 170 audio_path: Path, 171 171 vad_result: VadResult, 172 172 segment: str | None = None, 173 - remote: str | None = None, 173 + observer: str | None = None, 174 174 ) -> dict: 175 175 """Build base event dict for callosum emission. 176 176 ··· 178 178 audio_path: Path to the audio file 179 179 vad_result: VAD result with speech detection info 180 180 segment: Optional segment key (e.g., "143022_300") 181 - remote: Optional remote name 181 + observer: Optional observer name 182 182 183 183 Returns: 184 184 Event dict with common fields for observe.transcribed events ··· 207 207 event["day"] = day 208 208 if segment: 209 209 event["segment"] = segment 210 - if remote: 211 - event["remote"] = remote 210 + if observer: 211 + event["observer"] = observer 212 212 213 213 return event 214 214 ··· 314 314 base_datetime: datetime.datetime, 315 315 model_info: dict, 316 316 source: str | None = None, 317 - remote: str | None = None, 317 + observer: str | None = None, 318 318 enrichment: dict | None = None, 319 319 vad_result: VadResult | None = None, 320 320 segment_meta: dict | None = None, ··· 328 328 base_datetime: Base datetime for timestamp calculation 329 329 model_info: Dict with model, device, compute_type from backend 330 330 source: Optional source label (e.g., "mic", "sys") 331 - remote: Optional remote name for metadata 331 + observer: Optional observer name for metadata 332 332 enrichment: Optional enrichment data with topics, setting, warning, and 333 333 per-statement corrected text and emotions 334 334 vad_result: Optional VAD result for noise detection metadata ··· 347 347 "device": model_info.get("device", "unknown"), 348 348 "compute_type": model_info.get("compute_type", "unknown"), 349 349 } 350 - if remote: 351 - metadata["remote"] = remote 350 + if observer: 351 + metadata["observer"] = observer 352 352 353 353 # Add noise detection metadata if available 354 354 if vad_result: ··· 457 457 logging.info(f"Already processed: {raw_path}") 458 458 return 459 459 460 - # Get remote name once for use in metadata and events 461 - remote = os.getenv("REMOTE_NAME") 460 + # Get observer name once for use in metadata and events 461 + observer = os.getenv("OBSERVER_NAME") 462 462 463 463 # Get segment metadata (from sense.py via SEGMENT_META env var) 464 464 segment_meta = None ··· 524 524 preserve_all = config.get("transcribe", {}).get("preserve_all", False) 525 525 526 526 # Build base event fields (always emitted as observe.transcribed) 527 - event = _build_base_event(raw_path, vad_result, segment, remote) 527 + event = _build_base_event(raw_path, vad_result, segment, observer) 528 528 529 529 # Handle no speech detected 530 530 if not statements: ··· 593 593 base_dt, 594 594 model_info, 595 595 source, 596 - remote, 596 + observer, 597 597 enrichment, 598 598 vad_result, 599 599 segment_meta, ··· 656 656 657 657 # Early exit if no speech detected (skip loading heavy STT model) 658 658 if not vad_result.has_speech: 659 - remote = os.getenv("REMOTE_NAME") 659 + observer = os.getenv("OBSERVER_NAME") 660 660 segment = get_segment_key(audio_path) 661 - event = _build_base_event(audio_path, vad_result, segment, remote) 661 + event = _build_base_event(audio_path, vad_result, segment, observer) 662 662 663 663 if preserve_all: 664 664 event["outcome"] = "preserved"
+2 -2
sol.py
··· 60 60 "sense": "observe.sense", 61 61 "sync": "observe.sync", 62 62 "transfer": "observe.transfer", 63 - "remote": "observe.remote_cli", 63 + "observer": "observe.observer_cli", 64 64 # AI agents (talent package) 65 65 "agents": "think.agents", 66 66 "cortex": "think.cortex", ··· 116 116 "sense", 117 117 "sync", 118 118 "transfer", 119 - "remote", 119 + "observer", 120 120 ], 121 121 "Talent (AI agents)": [ 122 122 "agents",
+3
tests/baselines/api/observer/observer-key.json
··· 1 + { 2 + "error": "Observer not found" 3 + }
tests/baselines/api/remote/ingest-day.json tests/baselines/api/observer/ingest-day.json
tests/baselines/api/remote/list.json tests/baselines/api/observer/list.json
-3
tests/baselines/api/remote/remote-key.json
··· 1 - { 2 - "error": "Remote not found" 3 - }
+1 -1
tests/test_action_logging.py
··· 134 134 135 135 # Log a journal-level action 136 136 log_app_action( 137 - app="remote", 137 + app="observer", 138 138 facet=None, 139 139 action="observer_create", 140 140 params={"name": "test-observer"},
+1 -1
tests/test_init.py
··· 171 171 content_type="application/json", 172 172 ) 173 173 monkeypatch.setattr( 174 - "apps.remote.utils.list_remotes", 174 + "apps.observer.utils.list_observers", 175 175 lambda: [ 176 176 {"key": "abcd1234xxxx", "name": "my-phone", "created_at": 100, 177 177 "last_seen": None, "last_segment": None, "enabled": True,
+9 -9
tests/test_sense.py
··· 23 23 24 24 assert item.file_path == path 25 25 assert item.queued_at > 0 26 - assert item.remote is None 26 + assert item.observer is None 27 27 28 28 29 - def test_queued_item_with_remote(): 30 - """Test QueuedItem stores remote context.""" 29 + def test_queued_item_with_observer(): 30 + """Test QueuedItem stores observer context.""" 31 31 path = Path("/tmp/test.flac") 32 - item = QueuedItem(path, remote="my-remote") 32 + item = QueuedItem(path, observer="my-observer") 33 33 34 34 assert item.file_path == path 35 - assert item.remote == "my-remote" 35 + assert item.observer == "my-observer" 36 36 37 37 38 38 # --- HandlerQueue Tests --- ··· 72 72 assert queue.queue_size() == 1 73 73 74 74 75 - def test_handler_queue_enqueue_with_remote(): 76 - """Test enqueue preserves remote context.""" 75 + def test_handler_queue_enqueue_with_observer(): 76 + """Test enqueue preserves observer context.""" 77 77 queue = HandlerQueue("test") 78 78 path = Path("/tmp/test.flac") 79 79 80 - queue.enqueue(path, remote="my-remote") 80 + queue.enqueue(path, observer="my-observer") 81 81 82 82 assert queue.queue_size() == 1 83 83 item = queue.pop_next() 84 - assert item.remote == "my-remote" 84 + assert item.observer == "my-observer" 85 85 86 86 87 87 def test_handler_queue_pop_next():
+7 -7
tests/test_streams.py
··· 30 30 assert stream_name(host="archon", qualifier="tmux") == "archon.tmux" 31 31 32 32 33 - def test_stream_name_remote(): 34 - """Remote name -> remote name.""" 35 - assert stream_name(remote="laptop") == "laptop" 33 + def test_stream_name_observer(): 34 + """Observer name -> observer name.""" 35 + assert stream_name(observer="laptop") == "laptop" 36 36 37 37 38 38 def test_stream_name_import_apple(): ··· 50 50 assert stream_name(host="My Host") == "my-host" 51 51 assert stream_name(host="FOO/BAR") == "foo-bar" 52 52 assert stream_name(host=" ARCHON ") == "archon" 53 - assert stream_name(remote="My Laptop") == "my-laptop" 53 + assert stream_name(observer="My Laptop") == "my-laptop" 54 54 55 55 56 56 def test_stream_name_hostname_stripping(): 57 - """Domain suffixes are stripped from hostnames and remote names.""" 57 + """Domain suffixes are stripped from hostnames and observer names.""" 58 58 # .local, .home, .lan etc — keep only first label 59 59 assert stream_name(host="ja1r.local") == "ja1r" 60 60 assert stream_name(host="archon.home") == "archon" 61 61 assert stream_name(host="server.corp.example.com") == "server" 62 - assert stream_name(remote="phone.local") == "phone" 62 + assert stream_name(observer="phone.local") == "phone" 63 63 64 64 # With qualifier — dot is for qualifier only 65 65 assert stream_name(host="ja1r.local", qualifier="tmux") == "ja1r.tmux" 66 66 67 67 # Simple hostnames unchanged 68 68 assert stream_name(host="archon") == "archon" 69 - assert stream_name(remote="laptop") == "laptop" 69 + assert stream_name(observer="laptop") == "laptop" 70 70 71 71 # IP addresses become dash-separated 72 72 assert stream_name(host="192.168.1.1") == "192-168-1-1"
+8 -8
tests/verify_api.py
··· 155 155 "params": {}, 156 156 "status": 404, 157 157 }, 158 - # apps/remote/routes.py 158 + # apps/observer/routes.py 159 159 { 160 - "app": "remote", 160 + "app": "observer", 161 161 "name": "list", 162 - "path": "/app/remote/api/list", 162 + "path": "/app/observer/api/list", 163 163 "params": {}, 164 164 "status": 200, 165 165 }, 166 166 { 167 - "app": "remote", 168 - "name": "remote-key", 169 - "path": "/app/remote/api/example-key/key", 167 + "app": "observer", 168 + "name": "observer-key", 169 + "path": "/app/observer/api/example-key/key", 170 170 "params": {}, 171 171 "status": 404, 172 172 }, 173 173 { 174 - "app": "remote", 174 + "app": "observer", 175 175 "name": "ingest-day", 176 - "path": "/app/remote/ingest/example-key/segments/20260304", 176 + "path": "/app/observer/ingest/example-key/segments/20260304", 177 177 "params": {}, 178 178 "status": 401, 179 179 },
+2 -2
tests/verify_browser.py
··· 123 123 ], 124 124 }, 125 125 { 126 - "app": "remote", 126 + "app": "observer", 127 127 "name": "smoke", 128 128 "steps": [ 129 - {"do": "navigate", "path": "/app/remote"}, 129 + {"do": "navigate", "path": "/app/observer"}, 130 130 {"do": "wait", "ms": 1000}, 131 131 {"do": "screenshot"}, 132 132 ],
+8 -8
think/streams.py
··· 10 10 Naming convention (separator is '.'): 11 11 Local observer: {hostname} e.g. "archon" (domain stripped: archon.local -> archon) 12 12 Local tmux: {hostname}.tmux e.g. "archon.tmux" 13 - Remote observer: {remote_name} e.g. "laptop" (domain stripped: laptop.local -> laptop) 13 + Observer: {observer_name} e.g. "laptop" (domain stripped: laptop.local -> laptop) 14 14 Import (Apple): import.apple 15 15 Import (Plaud): import.plaud 16 16 Import (generic): import.audio ··· 63 63 def stream_name( 64 64 *, 65 65 host: str | None = None, 66 - remote: str | None = None, 66 + observer: str | None = None, 67 67 import_source: str | None = None, 68 68 qualifier: str | None = None, 69 69 ) -> str: 70 70 """Derive canonical stream name from source characteristics. 71 71 72 - Exactly one of host, remote, or import_source must be provided. 72 + Exactly one of host, observer, or import_source must be provided. 73 73 74 74 Parameters 75 75 ---------- 76 76 host : str, optional 77 77 Local hostname (e.g., "archon"). 78 - remote : str, optional 79 - Remote observer name (e.g., "laptop"). 78 + observer : str, optional 79 + Observer name (e.g., "laptop"). 80 80 import_source : str, optional 81 81 Import source type (e.g., "apple", "plaud", "audio", "text"). 82 82 qualifier : str, optional ··· 94 94 """ 95 95 if host: 96 96 base = _strip_hostname(host) 97 - elif remote: 98 - base = _strip_hostname(remote) 97 + elif observer: 98 + base = _strip_hostname(observer) 99 99 elif import_source: 100 100 base = f"import.{import_source}" 101 101 else: 102 - raise ValueError("stream_name requires host, remote, or import_source") 102 + raise ValueError("stream_name requires host, observer, or import_source") 103 103 104 104 # Sanitize: lowercase, replace spaces/slashes with dash, strip 105 105 name = base.lower().strip()