personal memory agent
0
fork

Configure Feed

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

Add remote app for distributed observer architecture

Enables splitting observer (capture) and processor (sense/transcribe)
across two machines. The remote app provides:

- Management UI at /app/remote to create/revoke remote connections
- URL-with-key authentication for secure file uploads
- /ingest/{key} endpoint receives segment files from remote observers
- Event relay endpoint forwards status events to local Callosum

Observer changes:
- New --remote URL flag to enable remote mode
- RemoteClient uploads files and relays events via HTTP
- Files deleted locally after successful upload

Includes 27 unit tests with mocked HTTP calls.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+1282 -3
+5
apps/remote/app.json
··· 1 + { 2 + "icon": "📡", 3 + "label": "Remote", 4 + "facets": false 5 + }
+1 -2
apps/remote/routes.py
··· 14 14 import base64 15 15 import json 16 16 import logging 17 + import re 17 18 import secrets 18 19 import time 19 20 from pathlib import Path 20 21 from typing import Any 21 - 22 - import re 23 22 24 23 from flask import Blueprint, jsonify, request 25 24 from werkzeug.utils import secure_filename
+2
apps/remote/tests/__init__.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc
+44
apps/remote/tests/conftest.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Self-contained fixtures for remote app tests. 5 + 6 + These fixtures are fully standalone and only depend on pytest builtins. 7 + No shared dependencies from the root conftest.py are required. 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + import pytest 13 + 14 + 15 + @pytest.fixture 16 + def remote_env(tmp_path, monkeypatch): 17 + """Create a temporary journal for remote app testing. 18 + 19 + Returns a factory function that sets up the environment and returns 20 + the Flask test client along with the journal path. 21 + """ 22 + 23 + def _create(): 24 + journal = tmp_path / "journal" 25 + journal.mkdir() 26 + 27 + # Set environment 28 + monkeypatch.setenv("JOURNAL_PATH", str(journal)) 29 + 30 + # Create Flask test client 31 + from convey import create_app 32 + 33 + app = create_app(journal=str(journal)) 34 + client = app.test_client() 35 + 36 + class Env: 37 + def __init__(self): 38 + self.journal = journal 39 + self.client = client 40 + self.app = app 41 + 42 + return Env() 43 + 44 + return _create
+237
apps/remote/tests/test_client.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for RemoteClient with mocked HTTP calls.""" 5 + 6 + from __future__ import annotations 7 + 8 + from pathlib import Path 9 + from unittest.mock import MagicMock, patch 10 + 11 + import pytest 12 + 13 + 14 + @pytest.fixture 15 + def mock_session(): 16 + """Create a mock requests session.""" 17 + with patch("observe.remote.requests.Session") as mock: 18 + session = MagicMock() 19 + mock.return_value = session 20 + yield session 21 + 22 + 23 + def test_remote_client_init(): 24 + """Test RemoteClient initialization.""" 25 + from observe.remote import RemoteClient 26 + 27 + client = RemoteClient("https://server:5000/app/remote/ingest/abc123") 28 + 29 + assert client.remote_url == "https://server:5000/app/remote/ingest/abc123" 30 + assert client.event_url == "https://server:5000/app/remote/ingest/abc123/event" 31 + 32 + 33 + def test_remote_client_emit_queues_event(): 34 + """Test that emit queues events.""" 35 + from observe.remote import RemoteClient 36 + 37 + client = RemoteClient("https://server/ingest/key") 38 + 39 + # Emit without starting (queue only, no sending) 40 + result = client.emit("observe", "status", mode="screencast") 41 + assert result is True 42 + 43 + # Check queue has the event 44 + event = client._event_queue.get_nowait() 45 + assert event["tract"] == "observe" 46 + assert event["event"] == "status" 47 + assert event["mode"] == "screencast" 48 + 49 + 50 + def test_remote_client_emit_queue_full(): 51 + """Test that emit returns False when queue is full.""" 52 + from observe.remote import RemoteClient 53 + 54 + client = RemoteClient("https://server/ingest/key") 55 + # Queue is maxsize=100, fill it up 56 + for i in range(100): 57 + client.emit("test", f"event_{i}") 58 + 59 + # Next emit should fail 60 + result = client.emit("test", "overflow") 61 + assert result is False 62 + 63 + 64 + def test_upload_segment_success(mock_session, tmp_path): 65 + """Test successful file upload.""" 66 + from observe.remote import RemoteClient 67 + 68 + # Create test files 69 + file1 = tmp_path / "audio.flac" 70 + file1.write_bytes(b"audio data") 71 + file2 = tmp_path / "video.webm" 72 + file2.write_bytes(b"video data") 73 + 74 + # Mock successful response 75 + mock_response = MagicMock() 76 + mock_response.status_code = 200 77 + mock_response.json.return_value = { 78 + "files": ["audio.flac", "video.webm"], 79 + "bytes": 20, 80 + } 81 + mock_session.post.return_value = mock_response 82 + 83 + client = RemoteClient("https://server/ingest/key") 84 + result = client.upload_segment("20250103", "120000_300", [file1, file2]) 85 + 86 + assert result is True 87 + mock_session.post.assert_called_once() 88 + 89 + # Check the call arguments 90 + call_args = mock_session.post.call_args 91 + assert call_args[0][0] == "https://server/ingest/key" 92 + assert call_args[1]["data"] == {"day": "20250103", "segment": "120000_300"} 93 + 94 + 95 + def test_upload_segment_retry_on_failure(mock_session, tmp_path): 96 + """Test that upload retries on failure.""" 97 + from observe.remote import RETRY_BACKOFF, RemoteClient 98 + 99 + # Create test file 100 + file1 = tmp_path / "audio.flac" 101 + file1.write_bytes(b"audio data") 102 + 103 + # Mock failure then success 104 + mock_failure = MagicMock() 105 + mock_failure.status_code = 500 106 + mock_failure.text = "Server error" 107 + 108 + mock_success = MagicMock() 109 + mock_success.status_code = 200 110 + mock_success.json.return_value = {"files": ["audio.flac"], "bytes": 10} 111 + 112 + mock_session.post.side_effect = [mock_failure, mock_success] 113 + 114 + # Patch sleep to avoid delays 115 + with patch("observe.remote.time.sleep"): 116 + client = RemoteClient("https://server/ingest/key") 117 + result = client.upload_segment("20250103", "120000_300", [file1]) 118 + 119 + assert result is True 120 + assert mock_session.post.call_count == 2 121 + 122 + 123 + def test_upload_segment_all_retries_fail(mock_session, tmp_path): 124 + """Test that upload returns False after all retries fail.""" 125 + from observe.remote import RETRY_BACKOFF, RemoteClient 126 + 127 + # Create test file 128 + file1 = tmp_path / "audio.flac" 129 + file1.write_bytes(b"audio data") 130 + 131 + # Mock all failures 132 + mock_failure = MagicMock() 133 + mock_failure.status_code = 500 134 + mock_failure.text = "Server error" 135 + mock_session.post.return_value = mock_failure 136 + 137 + # Patch sleep to avoid delays 138 + with patch("observe.remote.time.sleep"): 139 + client = RemoteClient("https://server/ingest/key") 140 + result = client.upload_segment("20250103", "120000_300", [file1]) 141 + 142 + assert result is False 143 + assert mock_session.post.call_count == len(RETRY_BACKOFF) 144 + 145 + 146 + def test_upload_segment_skips_missing_files(mock_session, tmp_path): 147 + """Test that upload skips missing files.""" 148 + from observe.remote import RemoteClient 149 + 150 + # Create one existing file 151 + file1 = tmp_path / "exists.flac" 152 + file1.write_bytes(b"data") 153 + 154 + # Reference a missing file 155 + file2 = tmp_path / "missing.flac" 156 + 157 + # Mock successful response 158 + mock_response = MagicMock() 159 + mock_response.status_code = 200 160 + mock_response.json.return_value = {"files": ["exists.flac"], "bytes": 4} 161 + mock_session.post.return_value = mock_response 162 + 163 + client = RemoteClient("https://server/ingest/key") 164 + result = client.upload_segment("20250103", "120000_300", [file1, file2]) 165 + 166 + assert result is True 167 + 168 + 169 + def test_upload_segment_fails_if_all_missing(mock_session, tmp_path): 170 + """Test that upload fails if all files are missing.""" 171 + from observe.remote import RemoteClient 172 + 173 + # Reference missing files 174 + file1 = tmp_path / "missing1.flac" 175 + file2 = tmp_path / "missing2.flac" 176 + 177 + client = RemoteClient("https://server/ingest/key") 178 + result = client.upload_segment("20250103", "120000_300", [file1, file2]) 179 + 180 + assert result is False 181 + mock_session.post.assert_not_called() 182 + 183 + 184 + def test_upload_segment_empty_list(mock_session): 185 + """Test that upload fails with empty file list.""" 186 + from observe.remote import RemoteClient 187 + 188 + client = RemoteClient("https://server/ingest/key") 189 + result = client.upload_segment("20250103", "120000_300", []) 190 + 191 + assert result is False 192 + mock_session.post.assert_not_called() 193 + 194 + 195 + def test_upload_and_cleanup_deletes_on_success(mock_session, tmp_path): 196 + """Test that upload_and_cleanup deletes files on success.""" 197 + from observe.remote import RemoteClient 198 + 199 + # Create test file 200 + file1 = tmp_path / "audio.flac" 201 + file1.write_bytes(b"audio data") 202 + assert file1.exists() 203 + 204 + # Mock successful response 205 + mock_response = MagicMock() 206 + mock_response.status_code = 200 207 + mock_response.json.return_value = {"files": ["audio.flac"], "bytes": 10} 208 + mock_session.post.return_value = mock_response 209 + 210 + client = RemoteClient("https://server/ingest/key") 211 + result = client.upload_and_cleanup("20250103", "120000_300", [file1]) 212 + 213 + assert result is True 214 + assert not file1.exists() # File should be deleted 215 + 216 + 217 + def test_upload_and_cleanup_keeps_on_failure(mock_session, tmp_path): 218 + """Test that upload_and_cleanup keeps files on failure.""" 219 + from observe.remote import RemoteClient 220 + 221 + # Create test file 222 + file1 = tmp_path / "audio.flac" 223 + file1.write_bytes(b"audio data") 224 + assert file1.exists() 225 + 226 + # Mock all failures 227 + mock_failure = MagicMock() 228 + mock_failure.status_code = 500 229 + mock_failure.text = "Error" 230 + mock_session.post.return_value = mock_failure 231 + 232 + with patch("observe.remote.time.sleep"): 233 + client = RemoteClient("https://server/ingest/key") 234 + result = client.upload_and_cleanup("20250103", "120000_300", [file1]) 235 + 236 + assert result is False 237 + assert file1.exists() # File should still exist
+344
apps/remote/tests/test_routes.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Tests for remote app routes.""" 5 + 6 + from __future__ import annotations 7 + 8 + import io 9 + import json 10 + 11 + 12 + def test_api_list_empty(remote_env): 13 + """Test listing remotes when none exist.""" 14 + env = remote_env() 15 + 16 + resp = env.client.get("/app/remote/api/list") 17 + assert resp.status_code == 200 18 + assert resp.get_json() == [] 19 + 20 + 21 + def test_api_create_remote(remote_env): 22 + """Test creating a new remote.""" 23 + env = remote_env() 24 + 25 + resp = env.client.post( 26 + "/app/remote/api/create", 27 + json={"name": "test-laptop"}, 28 + content_type="application/json", 29 + ) 30 + 31 + assert resp.status_code == 200 32 + data = resp.get_json() 33 + 34 + assert "key" in data 35 + assert len(data["key"]) > 32 # 256 bits = 43 base64 chars 36 + assert data["key_prefix"] == data["key"][:8] 37 + assert data["name"] == "test-laptop" 38 + assert "/app/remote/ingest/" in data["ingest_url"] 39 + 40 + 41 + def test_api_create_requires_name(remote_env): 42 + """Test that creating a remote requires a name.""" 43 + env = remote_env() 44 + 45 + # Missing name 46 + resp = env.client.post( 47 + "/app/remote/api/create", 48 + json={}, 49 + content_type="application/json", 50 + ) 51 + assert resp.status_code == 400 52 + assert "Name is required" in resp.get_json()["error"] 53 + 54 + # Empty name 55 + resp = env.client.post( 56 + "/app/remote/api/create", 57 + json={"name": " "}, 58 + content_type="application/json", 59 + ) 60 + assert resp.status_code == 400 61 + 62 + 63 + def test_api_list_shows_created_remote(remote_env): 64 + """Test that created remotes appear in the list.""" 65 + env = remote_env() 66 + 67 + # Create a remote 68 + resp = env.client.post( 69 + "/app/remote/api/create", 70 + json={"name": "my-remote"}, 71 + content_type="application/json", 72 + ) 73 + assert resp.status_code == 200 74 + key_prefix = resp.get_json()["key_prefix"] 75 + 76 + # List should show it 77 + resp = env.client.get("/app/remote/api/list") 78 + assert resp.status_code == 200 79 + remotes = resp.get_json() 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 86 + 87 + 88 + def test_api_delete_remote(remote_env): 89 + """Test deleting a remote.""" 90 + env = remote_env() 91 + 92 + # Create a remote 93 + resp = env.client.post( 94 + "/app/remote/api/create", 95 + json={"name": "to-delete"}, 96 + content_type="application/json", 97 + ) 98 + key_prefix = resp.get_json()["key_prefix"] 99 + 100 + # Delete it 101 + resp = env.client.delete(f"/app/remote/api/{key_prefix}") 102 + assert resp.status_code == 200 103 + assert resp.get_json()["status"] == "ok" 104 + 105 + # List should be empty 106 + resp = env.client.get("/app/remote/api/list") 107 + assert resp.get_json() == [] 108 + 109 + 110 + def test_api_delete_nonexistent(remote_env): 111 + """Test deleting a nonexistent remote returns 404.""" 112 + env = remote_env() 113 + 114 + resp = env.client.delete("/app/remote/api/nonexistent") 115 + assert resp.status_code == 404 116 + 117 + 118 + def test_ingest_invalid_key(remote_env): 119 + """Test that ingest rejects invalid keys.""" 120 + env = remote_env() 121 + 122 + resp = env.client.post( 123 + "/app/remote/ingest/invalid-key-12345", 124 + data={"day": "20250103", "segment": "120000_300"}, 125 + ) 126 + assert resp.status_code == 401 127 + assert "Invalid key" in resp.get_json()["error"] 128 + 129 + 130 + def test_ingest_missing_segment(remote_env): 131 + """Test that ingest requires segment.""" 132 + env = remote_env() 133 + 134 + # Create a remote 135 + resp = env.client.post( 136 + "/app/remote/api/create", 137 + json={"name": "test"}, 138 + content_type="application/json", 139 + ) 140 + key = resp.get_json()["key"] 141 + 142 + # Upload without segment 143 + resp = env.client.post( 144 + f"/app/remote/ingest/{key}", 145 + data={"day": "20250103"}, 146 + ) 147 + assert resp.status_code == 400 148 + assert "Missing segment" in resp.get_json()["error"] 149 + 150 + 151 + def test_ingest_missing_day(remote_env): 152 + """Test that ingest requires day.""" 153 + env = remote_env() 154 + 155 + # Create a remote 156 + resp = env.client.post( 157 + "/app/remote/api/create", 158 + json={"name": "test"}, 159 + content_type="application/json", 160 + ) 161 + key = resp.get_json()["key"] 162 + 163 + # Upload without day 164 + resp = env.client.post( 165 + f"/app/remote/ingest/{key}", 166 + data={"segment": "120000_300"}, 167 + ) 168 + assert resp.status_code == 400 169 + assert "Missing day" in resp.get_json()["error"] 170 + 171 + 172 + def test_ingest_invalid_segment_format(remote_env): 173 + """Test that ingest validates segment format.""" 174 + env = remote_env() 175 + 176 + # Create a remote 177 + resp = env.client.post( 178 + "/app/remote/api/create", 179 + json={"name": "test"}, 180 + content_type="application/json", 181 + ) 182 + key = resp.get_json()["key"] 183 + 184 + # Invalid segment format 185 + resp = env.client.post( 186 + f"/app/remote/ingest/{key}", 187 + data={"day": "20250103", "segment": "invalid"}, 188 + ) 189 + assert resp.status_code == 400 190 + assert "Invalid segment format" in resp.get_json()["error"] 191 + 192 + 193 + def test_ingest_invalid_day_format(remote_env): 194 + """Test that ingest validates day format.""" 195 + env = remote_env() 196 + 197 + # Create a remote 198 + resp = env.client.post( 199 + "/app/remote/api/create", 200 + json={"name": "test"}, 201 + content_type="application/json", 202 + ) 203 + key = resp.get_json()["key"] 204 + 205 + # Invalid day format 206 + resp = env.client.post( 207 + f"/app/remote/ingest/{key}", 208 + data={"day": "2025-01-03", "segment": "120000_300"}, 209 + ) 210 + assert resp.status_code == 400 211 + assert "Invalid day format" in resp.get_json()["error"] 212 + 213 + 214 + def test_ingest_no_files(remote_env): 215 + """Test that ingest requires files.""" 216 + env = remote_env() 217 + 218 + # Create a remote 219 + resp = env.client.post( 220 + "/app/remote/api/create", 221 + json={"name": "test"}, 222 + content_type="application/json", 223 + ) 224 + key = resp.get_json()["key"] 225 + 226 + # Upload without files 227 + resp = env.client.post( 228 + f"/app/remote/ingest/{key}", 229 + data={"day": "20250103", "segment": "120000_300"}, 230 + ) 231 + assert resp.status_code == 400 232 + assert "No files uploaded" in resp.get_json()["error"] 233 + 234 + 235 + def test_ingest_success(remote_env): 236 + """Test successful file ingest.""" 237 + env = remote_env() 238 + 239 + # Create a remote 240 + resp = env.client.post( 241 + "/app/remote/api/create", 242 + json={"name": "test-remote"}, 243 + content_type="application/json", 244 + ) 245 + key = resp.get_json()["key"] 246 + 247 + # Upload a file 248 + test_data = b"test audio content" 249 + resp = env.client.post( 250 + f"/app/remote/ingest/{key}", 251 + data={ 252 + "day": "20250103", 253 + "segment": "120000_300", 254 + "files": (io.BytesIO(test_data), "test_audio.flac"), 255 + }, 256 + ) 257 + assert resp.status_code == 200 258 + data = resp.get_json() 259 + assert data["status"] == "ok" 260 + assert data["files"] == ["test_audio.flac"] 261 + assert data["bytes"] == len(test_data) 262 + 263 + # Verify file was written 264 + expected_file = env.journal / "20250103" / "test_audio.flac" 265 + assert expected_file.exists() 266 + assert expected_file.read_bytes() == test_data 267 + 268 + 269 + def test_ingest_updates_stats(remote_env): 270 + """Test that ingest updates remote stats.""" 271 + env = remote_env() 272 + 273 + # Create a remote 274 + resp = env.client.post( 275 + "/app/remote/api/create", 276 + json={"name": "stats-test"}, 277 + content_type="application/json", 278 + ) 279 + key = resp.get_json()["key"] 280 + 281 + # Upload a file 282 + test_data = b"test content" 283 + resp = env.client.post( 284 + f"/app/remote/ingest/{key}", 285 + data={ 286 + "day": "20250103", 287 + "segment": "120000_300", 288 + "files": (io.BytesIO(test_data), "audio.flac"), 289 + }, 290 + ) 291 + assert resp.status_code == 200 292 + 293 + # Check stats updated 294 + resp = env.client.get("/app/remote/api/list") 295 + remotes = resp.get_json() 296 + assert len(remotes) == 1 297 + assert remotes[0]["stats"]["segments_received"] == 1 298 + assert remotes[0]["stats"]["bytes_received"] == len(test_data) 299 + assert remotes[0]["last_segment"] == "120000_300" 300 + assert remotes[0]["last_seen"] is not None 301 + 302 + 303 + def test_ingest_event_relay(remote_env): 304 + """Test event relay endpoint.""" 305 + env = remote_env() 306 + 307 + # Create a remote 308 + resp = env.client.post( 309 + "/app/remote/api/create", 310 + json={"name": "event-test"}, 311 + content_type="application/json", 312 + ) 313 + key = resp.get_json()["key"] 314 + 315 + # Send an event 316 + resp = env.client.post( 317 + f"/app/remote/ingest/{key}/event", 318 + json={"tract": "observe", "event": "status", "mode": "screencast"}, 319 + content_type="application/json", 320 + ) 321 + assert resp.status_code == 200 322 + assert resp.get_json()["status"] == "ok" 323 + 324 + 325 + def test_ingest_event_missing_tract(remote_env): 326 + """Test that event relay requires tract.""" 327 + env = remote_env() 328 + 329 + # Create a remote 330 + resp = env.client.post( 331 + "/app/remote/api/create", 332 + json={"name": "test"}, 333 + content_type="application/json", 334 + ) 335 + key = resp.get_json()["key"] 336 + 337 + # Missing tract 338 + resp = env.client.post( 339 + f"/app/remote/ingest/{key}/event", 340 + json={"event": "status"}, 341 + content_type="application/json", 342 + ) 343 + assert resp.status_code == 400 344 + assert "Missing tract or event" in resp.get_json()["error"]
+408
apps/remote/workspace.html
··· 1 + <style> 2 + .remote-card { 3 + background: #f8f9fa; 4 + border: 1px solid #dee2e6; 5 + border-radius: 8px; 6 + padding: 1em; 7 + margin-bottom: 1em; 8 + } 9 + .remote-card.disconnected { 10 + opacity: 0.7; 11 + } 12 + .remote-header { 13 + display: flex; 14 + justify-content: space-between; 15 + align-items: center; 16 + margin-bottom: 0.5em; 17 + } 18 + .remote-name { 19 + font-weight: bold; 20 + font-size: 1.1em; 21 + } 22 + .remote-status { 23 + display: inline-flex; 24 + align-items: center; 25 + gap: 6px; 26 + font-size: 0.9em; 27 + padding: 2px 8px; 28 + border-radius: 4px; 29 + } 30 + .remote-status.connected { 31 + background: #d4edda; 32 + color: #155724; 33 + } 34 + .remote-status.connected::before { 35 + content: ''; 36 + width: 8px; 37 + height: 8px; 38 + border-radius: 50%; 39 + background: #28a745; 40 + } 41 + .remote-status.disconnected { 42 + background: #f8d7da; 43 + color: #721c24; 44 + } 45 + .remote-status.disconnected::before { 46 + content: ''; 47 + width: 8px; 48 + height: 8px; 49 + border-radius: 50%; 50 + background: #dc3545; 51 + } 52 + .remote-stats { 53 + font-size: 0.9em; 54 + color: #666; 55 + margin-bottom: 0.5em; 56 + } 57 + .remote-stats span { 58 + margin-right: 1.5em; 59 + } 60 + .remote-actions { 61 + display: flex; 62 + gap: 0.5em; 63 + } 64 + .remote-actions button { 65 + padding: 4px 12px; 66 + border: 1px solid #ccc; 67 + border-radius: 4px; 68 + background: white; 69 + cursor: pointer; 70 + font-size: 0.9em; 71 + } 72 + .remote-actions button:hover { 73 + background: #f0f0f0; 74 + } 75 + .remote-actions button.danger { 76 + color: #dc3545; 77 + border-color: #dc3545; 78 + } 79 + .remote-actions button.danger:hover { 80 + background: #f8d7da; 81 + } 82 + 83 + /* Add remote form */ 84 + .add-remote-form { 85 + display: flex; 86 + gap: 0.5em; 87 + margin-bottom: 1.5em; 88 + } 89 + .add-remote-form input { 90 + flex: 1; 91 + padding: 8px 12px; 92 + border: 1px solid #ccc; 93 + border-radius: 4px; 94 + font-size: 1em; 95 + } 96 + .add-remote-form button { 97 + padding: 8px 16px; 98 + background: #007bff; 99 + color: white; 100 + border: none; 101 + border-radius: 4px; 102 + cursor: pointer; 103 + font-weight: bold; 104 + } 105 + .add-remote-form button:hover { 106 + background: #0056b3; 107 + } 108 + .add-remote-form button:disabled { 109 + background: #ccc; 110 + cursor: not-allowed; 111 + } 112 + 113 + /* New remote modal */ 114 + .modal { 115 + display: none; 116 + position: fixed; 117 + z-index: 1000; 118 + left: 0; 119 + top: 0; 120 + width: 100%; 121 + height: 100%; 122 + background: rgba(0,0,0,0.5); 123 + } 124 + .modal-content { 125 + background: white; 126 + margin: 10% auto; 127 + padding: 1.5em; 128 + border-radius: 8px; 129 + max-width: 600px; 130 + position: relative; 131 + } 132 + .modal-close { 133 + position: absolute; 134 + top: 10px; 135 + right: 15px; 136 + cursor: pointer; 137 + font-size: 24px; 138 + color: #666; 139 + } 140 + .modal-close:hover { 141 + color: #333; 142 + } 143 + .modal h3 { 144 + margin-top: 0; 145 + margin-bottom: 1em; 146 + } 147 + .command-box { 148 + background: #1e1e1e; 149 + color: #d4d4d4; 150 + padding: 1em; 151 + border-radius: 4px; 152 + font-family: monospace; 153 + font-size: 0.9em; 154 + overflow-x: auto; 155 + margin: 1em 0; 156 + position: relative; 157 + } 158 + .command-box code { 159 + white-space: pre-wrap; 160 + word-break: break-all; 161 + } 162 + .copy-btn { 163 + position: absolute; 164 + top: 8px; 165 + right: 8px; 166 + padding: 4px 8px; 167 + background: #444; 168 + color: white; 169 + border: none; 170 + border-radius: 4px; 171 + cursor: pointer; 172 + font-size: 0.8em; 173 + } 174 + .copy-btn:hover { 175 + background: #555; 176 + } 177 + .copy-btn.copied { 178 + background: #28a745; 179 + } 180 + .modal-actions { 181 + display: flex; 182 + justify-content: flex-end; 183 + margin-top: 1em; 184 + } 185 + .modal-actions button { 186 + padding: 8px 16px; 187 + border-radius: 4px; 188 + cursor: pointer; 189 + } 190 + 191 + .no-remotes { 192 + color: #666; 193 + font-style: italic; 194 + text-align: center; 195 + padding: 2em; 196 + } 197 + 198 + .section-title { 199 + margin-bottom: 1em; 200 + color: #333; 201 + } 202 + </style> 203 + 204 + <div class="workspace-content"> 205 + <h2 class="section-title">Add Remote Observer</h2> 206 + <form class="add-remote-form" id="addRemoteForm"> 207 + <input type="text" id="remoteName" placeholder="Remote name (e.g., laptop, desktop)" required> 208 + <button type="submit">Add Remote</button> 209 + </form> 210 + 211 + <h2 class="section-title">Connected Remotes</h2> 212 + <div id="remotesList"> 213 + <div class="no-remotes">Loading...</div> 214 + </div> 215 + </div> 216 + 217 + <!-- New Remote Modal --> 218 + <div id="newRemoteModal" class="modal"> 219 + <div class="modal-content"> 220 + <span class="modal-close">&times;</span> 221 + <h3>Remote Created: <span id="modalRemoteName"></span></h3> 222 + <p>Run this command on your observer machine:</p> 223 + <div class="command-box"> 224 + <code id="commandText"></code> 225 + <button class="copy-btn" id="copyBtn">Copy</button> 226 + </div> 227 + <p style="font-size: 0.9em; color: #666;"> 228 + The observer will upload captured segments to this server for processing. 229 + Keep this URL secret - anyone with it can upload files to your journal. 230 + </p> 231 + <div class="modal-actions"> 232 + <button id="doneBtn" style="background: #007bff; color: white; border: none;">Done</button> 233 + </div> 234 + </div> 235 + </div> 236 + 237 + <script> 238 + const remotesList = document.getElementById('remotesList'); 239 + const addRemoteForm = document.getElementById('addRemoteForm'); 240 + const remoteNameInput = document.getElementById('remoteName'); 241 + const newRemoteModal = document.getElementById('newRemoteModal'); 242 + const modalRemoteName = document.getElementById('modalRemoteName'); 243 + const commandText = document.getElementById('commandText'); 244 + const copyBtn = document.getElementById('copyBtn'); 245 + const doneBtn = document.getElementById('doneBtn'); 246 + 247 + function formatBytes(bytes) { 248 + if (bytes === 0) return '0 B'; 249 + const k = 1024; 250 + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; 251 + const i = Math.floor(Math.log(bytes) / Math.log(k)); 252 + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; 253 + } 254 + 255 + function formatTimeAgo(timestamp) { 256 + if (!timestamp) return 'never'; 257 + const seconds = Math.floor((Date.now() - timestamp) / 1000); 258 + if (seconds < 60) return 'just now'; 259 + if (seconds < 3600) return `${Math.floor(seconds / 60)} min ago`; 260 + if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours ago`; 261 + return `${Math.floor(seconds / 86400)} days ago`; 262 + } 263 + 264 + function isConnected(lastSeen) { 265 + if (!lastSeen) return false; 266 + // Consider connected if seen in last 2 minutes 267 + return (Date.now() - lastSeen) < 120000; 268 + } 269 + 270 + async function loadRemotes() { 271 + try { 272 + const response = await fetch('/app/remote/api/list'); 273 + const remotes = await response.json(); 274 + 275 + if (!remotes || remotes.length === 0) { 276 + remotesList.innerHTML = '<div class="no-remotes">No remotes registered yet. Add one above to get started.</div>'; 277 + return; 278 + } 279 + 280 + let html = ''; 281 + for (const remote of remotes) { 282 + const connected = isConnected(remote.last_seen); 283 + const statusClass = connected ? 'connected' : 'disconnected'; 284 + const statusText = connected ? 'Connected' : 'Disconnected'; 285 + 286 + html += ` 287 + <div class="remote-card ${statusClass}" data-key="${remote.key_prefix}"> 288 + <div class="remote-header"> 289 + <span class="remote-name">${escapeHtml(remote.name)}</span> 290 + <span class="remote-status ${statusClass}">${statusText}</span> 291 + </div> 292 + <div class="remote-stats"> 293 + <span>Last seen: ${formatTimeAgo(remote.last_seen)}</span> 294 + <span>Segments: ${remote.stats?.segments_received || 0}</span> 295 + <span>Data: ${formatBytes(remote.stats?.bytes_received || 0)}</span> 296 + </div> 297 + <div class="remote-actions"> 298 + <button class="danger" onclick="revokeRemote('${remote.key_prefix}', '${escapeHtml(remote.name)}')">Revoke</button> 299 + </div> 300 + </div> 301 + `; 302 + } 303 + remotesList.innerHTML = html; 304 + } catch (err) { 305 + remotesList.innerHTML = '<div class="no-remotes">Error loading remotes</div>'; 306 + console.error('Failed to load remotes:', err); 307 + } 308 + } 309 + 310 + function escapeHtml(text) { 311 + const div = document.createElement('div'); 312 + div.textContent = text; 313 + return div.innerHTML; 314 + } 315 + 316 + async function revokeRemote(keyPrefix, name) { 317 + if (!confirm(`Revoke remote "${name}"? The observer will no longer be able to upload.`)) { 318 + return; 319 + } 320 + 321 + try { 322 + const response = await fetch(`/app/remote/api/${keyPrefix}`, { 323 + method: 'DELETE' 324 + }); 325 + 326 + if (!response.ok) { 327 + const data = await response.json(); 328 + throw new Error(data.error || 'Failed to revoke'); 329 + } 330 + 331 + loadRemotes(); 332 + } catch (err) { 333 + if (window.showError) showError(err.message); 334 + } 335 + } 336 + 337 + addRemoteForm.onsubmit = async (e) => { 338 + e.preventDefault(); 339 + const name = remoteNameInput.value.trim(); 340 + if (!name) return; 341 + 342 + const submitBtn = addRemoteForm.querySelector('button[type="submit"]'); 343 + submitBtn.disabled = true; 344 + 345 + try { 346 + const response = await fetch('/app/remote/api/create', { 347 + method: 'POST', 348 + headers: { 'Content-Type': 'application/json' }, 349 + body: JSON.stringify({ name }) 350 + }); 351 + 352 + const data = await response.json(); 353 + 354 + if (!response.ok) { 355 + throw new Error(data.error || 'Failed to create remote'); 356 + } 357 + 358 + // Build full URL 359 + const baseUrl = window.location.origin; 360 + const fullUrl = `${baseUrl}${data.ingest_url}`; 361 + 362 + // Show modal with command 363 + modalRemoteName.textContent = name; 364 + commandText.textContent = `observer --remote ${fullUrl}`; 365 + newRemoteModal.style.display = 'block'; 366 + 367 + // Clear input and reload list 368 + remoteNameInput.value = ''; 369 + loadRemotes(); 370 + } catch (err) { 371 + if (window.showError) showError(err.message); 372 + } finally { 373 + submitBtn.disabled = false; 374 + } 375 + }; 376 + 377 + // Modal controls 378 + document.querySelector('.modal-close').onclick = () => { 379 + newRemoteModal.style.display = 'none'; 380 + }; 381 + 382 + doneBtn.onclick = () => { 383 + newRemoteModal.style.display = 'none'; 384 + }; 385 + 386 + window.onclick = (e) => { 387 + if (e.target === newRemoteModal) { 388 + newRemoteModal.style.display = 'none'; 389 + } 390 + }; 391 + 392 + copyBtn.onclick = () => { 393 + navigator.clipboard.writeText(commandText.textContent).then(() => { 394 + copyBtn.textContent = 'Copied!'; 395 + copyBtn.classList.add('copied'); 396 + setTimeout(() => { 397 + copyBtn.textContent = 'Copy'; 398 + copyBtn.classList.remove('copied'); 399 + }, 2000); 400 + }); 401 + }; 402 + 403 + // Initial load 404 + loadRemotes(); 405 + 406 + // Refresh every 30 seconds to update connection status 407 + setInterval(loadRemotes, 30000); 408 + </script>
+21 -1
observe/linux/observer.py
··· 50 50 RMS_THRESHOLD = 0.01 51 51 MIN_HITS_FOR_SAVE = 3 52 52 CHUNK_DURATION = 5 # seconds 53 + STALL_THRESHOLD_CHUNKS = 3 # Exit after this many chunks with no file growth 53 54 54 55 # Host identification for multi-host scenarios 55 56 _HOST = socket.gethostname() ··· 107 108 108 109 # Health tracking - whether screencast files are actively growing 109 110 self.files_growing = False 111 + self.stalled_chunks = ( 112 + 0 # Consecutive chunks with no file growth in screencast mode 113 + ) 110 114 111 115 async def setup(self): 112 116 """Initialize audio devices and DBus connection.""" ··· 307 311 stopped_streams = await self.screencaster.stop() 308 312 self.current_streams = [] 309 313 self.last_screencast_sizes = {} 314 + self.stalled_chunks = 0 310 315 311 316 # Build finalization list and file names 312 317 finalizations = [] ··· 407 412 408 413 self.current_streams = streams 409 414 self.last_screencast_sizes = {s.temp_path: 0 for s in streams} 415 + self.stalled_chunks = 0 410 416 411 417 logger.info(f"Started screencast with {len(streams)} stream(s)") 412 418 for stream in streams: ··· 603 609 604 610 self.current_streams = [] 605 611 self.last_screencast_sizes = {} 612 + self.stalled_chunks = 0 606 613 # Force recalculate mode without screencast 607 614 self.current_mode = MODE_IDLE 608 615 ··· 671 678 any_growing = True 672 679 self.last_screencast_sizes[stream.temp_path] = current_size 673 680 self.files_growing = any_growing 681 + 682 + # Fail-fast: exit if screencast stalled (files not growing) 683 + if any_growing: 684 + self.stalled_chunks = 0 685 + else: 686 + self.stalled_chunks += 1 687 + if self.stalled_chunks >= STALL_THRESHOLD_CHUNKS: 688 + logger.error( 689 + f"Screencast stalled for {self.stalled_chunks} chunks " 690 + f"({self.stalled_chunks * CHUNK_DURATION}s), exiting" 691 + ) 692 + self.running = False 674 693 else: 675 694 self.files_growing = False 695 + self.stalled_chunks = 0 676 696 677 - # Emit status event (supervisor derives health from this) 697 + # Emit status event 678 698 self.emit_status() 679 699 680 700 # Cleanup on exit
+220
observe/remote.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Remote client for uploading observer data to a remote server. 5 + 6 + This module provides functionality for: 7 + - Uploading segment files to a remote server 8 + - Sending events to the remote Callosum relay 9 + - Retry logic for failed uploads 10 + """ 11 + 12 + from __future__ import annotations 13 + 14 + import logging 15 + import queue 16 + import threading 17 + import time 18 + from pathlib import Path 19 + 20 + import requests 21 + 22 + logger = logging.getLogger(__name__) 23 + 24 + # Retry configuration 25 + MAX_RETRIES = 3 26 + RETRY_BACKOFF = [1, 5, 15] # seconds 27 + UPLOAD_TIMEOUT = 300 # 5 minutes for large files 28 + 29 + 30 + class RemoteClient: 31 + """Client for uploading segment files and events to a remote server.""" 32 + 33 + def __init__(self, remote_url: str): 34 + """Initialize remote client. 35 + 36 + Args: 37 + remote_url: Full URL to remote ingest endpoint (including key) 38 + e.g., "https://server:5000/app/remote/ingest/abc123..." 39 + """ 40 + self.remote_url = remote_url.rstrip("/") 41 + self.event_url = f"{self.remote_url}/event" 42 + self.session = requests.Session() 43 + 44 + # Event queue for async sending 45 + self._event_queue: queue.Queue = queue.Queue(maxsize=100) 46 + self._event_thread: threading.Thread | None = None 47 + self._stop_event = threading.Event() 48 + 49 + def start(self) -> None: 50 + """Start background event sender thread.""" 51 + if self._event_thread and self._event_thread.is_alive(): 52 + return 53 + 54 + self._stop_event.clear() 55 + self._event_thread = threading.Thread(target=self._event_loop, daemon=True) 56 + self._event_thread.start() 57 + logger.info(f"Remote client started: {self.remote_url[:50]}...") 58 + 59 + def stop(self) -> None: 60 + """Stop background event sender thread.""" 61 + self._stop_event.set() 62 + if self._event_thread: 63 + self._event_thread.join(timeout=2.0) 64 + 65 + def _event_loop(self) -> None: 66 + """Background loop to send queued events.""" 67 + while not self._stop_event.is_set(): 68 + try: 69 + event = self._event_queue.get(timeout=1.0) 70 + self._send_event_sync(event) 71 + except queue.Empty: 72 + continue 73 + except Exception as e: 74 + logger.error(f"Event send error: {e}") 75 + 76 + def _send_event_sync(self, event: dict) -> bool: 77 + """Send a single event to remote server.""" 78 + for attempt, delay in enumerate(RETRY_BACKOFF): 79 + try: 80 + response = self.session.post( 81 + self.event_url, 82 + json=event, 83 + timeout=10, 84 + ) 85 + if response.status_code == 200: 86 + return True 87 + logger.warning( 88 + f"Event send failed: {response.status_code} {response.text}" 89 + ) 90 + except requests.RequestException as e: 91 + logger.warning(f"Event send attempt {attempt + 1} failed: {e}") 92 + 93 + if attempt < len(RETRY_BACKOFF) - 1: 94 + time.sleep(delay) 95 + 96 + logger.error(f"Event send failed after {MAX_RETRIES} attempts") 97 + return False 98 + 99 + def emit(self, tract: str, event: str, **fields) -> bool: 100 + """Queue an event to be sent to remote server. 101 + 102 + Args: 103 + tract: Event tract (e.g., "observe") 104 + event: Event name (e.g., "status") 105 + **fields: Additional event fields 106 + 107 + Returns: 108 + True if queued successfully, False if queue full 109 + """ 110 + message = {"tract": tract, "event": event, **fields} 111 + try: 112 + self._event_queue.put_nowait(message) 113 + return True 114 + except queue.Full: 115 + logger.warning(f"Event queue full, dropping: {tract}/{event}") 116 + return False 117 + 118 + def upload_segment( 119 + self, 120 + day: str, 121 + segment: str, 122 + files: list[Path], 123 + ) -> bool: 124 + """Upload segment files to remote server. 125 + 126 + Args: 127 + day: Day string (YYYYMMDD) 128 + segment: Segment key (HHMMSS_LEN) 129 + files: List of file paths to upload 130 + 131 + Returns: 132 + True if upload succeeded, False otherwise 133 + """ 134 + if not files: 135 + logger.warning("No files to upload") 136 + return False 137 + 138 + for attempt, delay in enumerate(RETRY_BACKOFF): 139 + # Open file handles and ensure they're closed 140 + file_handles = [] 141 + files_data = [] 142 + try: 143 + # Build files list for requests 144 + for path in files: 145 + if not path.exists(): 146 + logger.warning(f"File not found, skipping: {path}") 147 + continue 148 + fh = open(path, "rb") 149 + file_handles.append(fh) 150 + files_data.append( 151 + ("files", (path.name, fh, "application/octet-stream")) 152 + ) 153 + 154 + if not files_data: 155 + logger.error("No valid files to upload") 156 + return False 157 + 158 + # Send request 159 + response = self.session.post( 160 + self.remote_url, 161 + data={"day": day, "segment": segment}, 162 + files=files_data, 163 + timeout=UPLOAD_TIMEOUT, 164 + ) 165 + 166 + if response.status_code == 200: 167 + result = response.json() 168 + logger.info( 169 + f"Uploaded {len(result.get('files', []))} files " 170 + f"({result.get('bytes', 0)} bytes) for {day}/{segment}" 171 + ) 172 + return True 173 + 174 + logger.warning(f"Upload failed: {response.status_code} {response.text}") 175 + 176 + except requests.RequestException as e: 177 + logger.warning(f"Upload attempt {attempt + 1} failed: {e}") 178 + 179 + finally: 180 + # Always close file handles 181 + for fh in file_handles: 182 + try: 183 + fh.close() 184 + except Exception: 185 + pass 186 + 187 + if attempt < len(RETRY_BACKOFF) - 1: 188 + logger.info(f"Retrying upload in {delay}s...") 189 + time.sleep(delay) 190 + 191 + logger.error(f"Upload failed after {MAX_RETRIES} attempts: {day}/{segment}") 192 + return False 193 + 194 + def upload_and_cleanup( 195 + self, 196 + day: str, 197 + segment: str, 198 + files: list[Path], 199 + ) -> bool: 200 + """Upload segment files and delete local copies on success. 201 + 202 + Args: 203 + day: Day string (YYYYMMDD) 204 + segment: Segment key (HHMMSS_LEN) 205 + files: List of file paths to upload 206 + 207 + Returns: 208 + True if upload succeeded and files deleted, False otherwise 209 + """ 210 + if self.upload_segment(day, segment, files): 211 + # Delete local files on success 212 + for path in files: 213 + try: 214 + if path.exists(): 215 + path.unlink() 216 + logger.debug(f"Deleted local file: {path}") 217 + except OSError as e: 218 + logger.warning(f"Failed to delete {path}: {e}") 219 + return True 220 + return False