diff --git a/docs/plans/2026-04-08-federation-state-propagation-plan.md b/docs/plans/2026-04-08-federation-state-propagation-plan.md new file mode 100644 index 0000000..cee7fc3 --- /dev/null +++ b/docs/plans/2026-04-08-federation-state-propagation-plan.md @@ -0,0 +1,1391 @@ +# Federation State Propagation Implementation Plan + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** Make federation a seamless experience where settings, hidden sessions, and activity state propagate across all connected muxplex servers. +**Architecture:** Two-phase approach. Phase 1 fixes four federation bugs (falsy-0 remoteId, hidden session filtering in browser indicators, heartbeat-driven bell clearing across federation). Phase 2 adds a minimal P2P settings sync protocol using full-document push with per-server timestamps. All changes are in `muxplex/settings.py`, `muxplex/main.py`, and `muxplex/frontend/app.js`. +**Tech Stack:** Python 3.11+ / FastAPI / httpx / vanilla JS / pytest / Node.js test runner + +--- + +## Phase 1: Federation Bug Fixes + +### Task 1: Fix `getVisibleSessions()` falsy-0 bug + +**Files:** +- Modify: `muxplex/frontend/app.js:533` +- Test: `muxplex/frontend/tests/test_app.mjs` (add new tests) +- Test: `muxplex/tests/test_frontend_js.py` (add pattern test) + +**Step 1: Write the failing tests** + +In `muxplex/frontend/tests/test_app.mjs`, add these tests. Find the section that tests `getVisibleSessions` (search for `getVisibleSessions` in the test file) and add after the existing tests: + +```javascript +test('getVisibleSessions does NOT hide sessions with remoteId 0 whose name is in hidden_sessions', function() { + // remoteId: 0 is the first remote instance — it's a valid remote, not local. + // The hidden_sessions filter should only apply to local sessions (remoteId == null). + app._setServerSettings({ hidden_sessions: ['build'] }); + app._setCurrentSessions([ + { name: 'build', remoteId: 0, bell: { unseen_count: 0 } }, + ]); + var visible = app.getVisibleSessions(app._getCurrentSessions()); + assert.strictEqual(visible.length, 1, + 'Session with remoteId: 0 must NOT be hidden — 0 is a valid remote index, not null'); + app._setServerSettings(null); +}); + +test('getVisibleSessions hides local session (remoteId null) but keeps remote session (remoteId 0) with same name', function() { + app._setServerSettings({ hidden_sessions: ['build'] }); + app._setCurrentSessions([ + { name: 'build', remoteId: null, bell: { unseen_count: 0 } }, + { name: 'build', remoteId: 0, bell: { unseen_count: 0 } }, + ]); + var visible = app.getVisibleSessions(app._getCurrentSessions()); + assert.strictEqual(visible.length, 1, + 'Only the local session should be hidden; remote with remoteId 0 should remain'); + assert.strictEqual(visible[0].remoteId, 0, + 'The surviving session must be the remote one (remoteId 0)'); + app._setServerSettings(null); +}); +``` + +In `muxplex/tests/test_frontend_js.py`, add a pattern test at the end of the file: + +```python +def test_get_visible_sessions_uses_null_check_not_falsy() -> None: + """getVisibleSessions must use 's.remoteId == null' (not '!s.remoteId') to detect local sessions. + + The falsy check '!s.remoteId' incorrectly treats remoteId: 0 (first remote instance) + as falsy, hiding those sessions when they shouldn't be. + """ + # Extract the getVisibleSessions function body + match = re.search(r"function getVisibleSessions\b[^{]*\{(.*?)\n\}", _JS, re.DOTALL) + assert match, "getVisibleSessions function not found in app.js" + body = match.group(1) + assert "!s.remoteId" not in body, ( + "getVisibleSessions must NOT use '!s.remoteId' — it treats remoteId:0 as falsy. " + "Use 's.remoteId == null' instead." + ) + assert "s.remoteId == null" in body or "s.remoteId === null" in body, ( + "getVisibleSessions must use 's.remoteId == null' to detect local sessions" + ) +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | grep -E "FAIL|falsy|remoteId 0"` +Expected: FAIL — the current code uses `!s.remoteId` which treats `0` as falsy. + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_frontend_js.py::test_get_visible_sessions_uses_null_check_not_falsy -v` +Expected: FAIL — `!s.remoteId` is still in the code. + +**Step 3: Write minimal implementation** + +In `muxplex/frontend/app.js`, change line 533 from: + +```javascript + if (hidden.length > 0 && !s.remoteId && hidden.includes(s.name)) { +``` + +to: + +```javascript + if (hidden.length > 0 && s.remoteId == null && hidden.includes(s.name)) { +``` + +That's it. One character change (plus a few more for the condition). `s.remoteId == null` correctly treats `0` as truthy (a real remote) while still matching `null` and `undefined` (local sessions). + +**Step 4: Run tests to verify they pass** + +Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -3` +Expected: All tests pass (including the two new ones). + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_frontend_js.py::test_get_visible_sessions_uses_null_check_not_falsy -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs muxplex/tests/test_frontend_js.py && git commit -m "fix: getVisibleSessions falsy-0 bug — use s.remoteId == null instead of !s.remoteId" +``` + +--- + +### Task 2: Fix `updatePageTitle()` and `updateFaviconBadge()` to filter hidden sessions + +**Files:** +- Modify: `muxplex/frontend/app.js:1093-1130` (two functions) +- Test: `muxplex/frontend/tests/test_app.mjs` (add new tests) +- Test: `muxplex/tests/test_frontend_js.py` (add pattern tests) + +**Step 1: Write the failing tests** + +In `muxplex/frontend/tests/test_app.mjs`, add tests near the existing `updatePageTitle` / `updateFaviconBadge` tests: + +```javascript +test('updatePageTitle excludes hidden sessions from bell count', function() { + app._setServerSettings({ hidden_sessions: ['hidden-build'], device_name: 'myhost' }); + app._setCurrentSessions([ + { name: 'visible-dev', remoteId: null, bell: { unseen_count: 2 } }, + { name: 'hidden-build', remoteId: null, bell: { unseen_count: 5 } }, + ]); + app.updatePageTitle(); + // Only 'visible-dev' should be counted (1 session with bells), not 'hidden-build' + assert.ok(document.title.startsWith('(1)'), + 'Title must show (1) — the hidden session bell count must be excluded. Got: ' + document.title); + app._setServerSettings(null); +}); + +test('updateFaviconBadge does not show activity for only-hidden sessions with bells', function() { + // When ALL sessions with bells are hidden, there should be no badge + app._setServerSettings({ hidden_sessions: ['hidden-build'] }); + app._setCurrentSessions([ + { name: 'hidden-build', remoteId: null, bell: { unseen_count: 3 } }, + { name: 'visible-dev', remoteId: null, bell: { unseen_count: 0 } }, + ]); + app.updateFaviconBadge(); + // The favicon link should be restored to original (no badge) + var link = document.querySelector('link[rel="icon"]'); + // If _drawFaviconBadge was called, the href would be a data:image/png URL + // We just verify the function body uses getVisibleSessions via the pattern test + app._setServerSettings(null); +}); +``` + +In `muxplex/tests/test_frontend_js.py`, add pattern tests: + +```python +def test_update_page_title_filters_through_visible_sessions() -> None: + """updatePageTitle must filter through getVisibleSessions before counting bells.""" + match = re.search(r"function updatePageTitle\b[^{]*\{(.*?)\n\}", _JS, re.DOTALL) + assert match, "updatePageTitle function not found in app.js" + body = match.group(1) + assert "getVisibleSessions" in body, ( + "updatePageTitle must call getVisibleSessions to exclude hidden sessions from bell count" + ) + + +def test_update_favicon_badge_filters_through_visible_sessions() -> None: + """updateFaviconBadge must filter through getVisibleSessions before checking bells.""" + match = re.search(r"function updateFaviconBadge\b[^{]*\{(.*?)\n\}", _JS, re.DOTALL) + assert match, "updateFaviconBadge function not found in app.js" + body = match.group(1) + assert "getVisibleSessions" in body, ( + "updateFaviconBadge must call getVisibleSessions to exclude hidden sessions from activity check" + ) +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_frontend_js.py::test_update_page_title_filters_through_visible_sessions muxplex/tests/test_frontend_js.py::test_update_favicon_badge_filters_through_visible_sessions -v` +Expected: Both FAIL — neither function currently calls `getVisibleSessions`. + +**Step 3: Write minimal implementation** + +In `muxplex/frontend/app.js`, change `updateFaviconBadge()` (around line 1093): + +From: +```javascript +function updateFaviconBadge() { + var hasActivity = _currentSessions && _currentSessions.some(function (s) { + return s.bell && s.bell.unseen_count > 0; + }); +``` + +To: +```javascript +function updateFaviconBadge() { + var visible = getVisibleSessions(_currentSessions); + var hasActivity = visible.length > 0 && visible.some(function (s) { + return s.bell && s.bell.unseen_count > 0; + }); +``` + +In `muxplex/frontend/app.js`, change `updatePageTitle()` (around line 1121): + +From: +```javascript +function updatePageTitle() { + var hostname = (_serverSettings && _serverSettings.device_name) || + (typeof location !== 'undefined' ? location.hostname : null) || + 'muxplex'; + var count = (_currentSessions || []).filter(function(s) { + return s.bell && s.bell.unseen_count > 0; + }).length; +``` + +To: +```javascript +function updatePageTitle() { + var hostname = (_serverSettings && _serverSettings.device_name) || + (typeof location !== 'undefined' ? location.hostname : null) || + 'muxplex'; + var visible = getVisibleSessions(_currentSessions); + var count = visible.filter(function(s) { + return s.bell && s.bell.unseen_count > 0; + }).length; +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_frontend_js.py::test_update_page_title_filters_through_visible_sessions muxplex/tests/test_frontend_js.py::test_update_favicon_badge_filters_through_visible_sessions -v` +Expected: Both PASS. + +Run: `cd muxplex && node --test muxplex/frontend/tests/test_app.mjs 2>&1 | tail -3` +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/frontend/app.js muxplex/frontend/tests/test_app.mjs muxplex/tests/test_frontend_js.py && git commit -m "fix: updatePageTitle and updateFaviconBadge now filter hidden sessions via getVisibleSessions" +``` + +--- + +### Task 3: Add heartbeat-driven bell clearing for remote sessions in poll cycle + +**Files:** +- Modify: `muxplex/main.py:91-138` (add step 12 to `_run_poll_cycle`) +- Modify: `muxplex/main.py:79-83` (add module-level `_federation_client` reference) +- Modify: `muxplex/main.py:161-189` (set `_federation_client` in lifespan) +- Test: `muxplex/tests/test_settings.py` or new test in `muxplex/tests/test_api.py` + +**Important context:** The `_run_poll_cycle()` function runs in a background task, NOT inside a request handler. It does NOT have access to `request.app.state.federation_client`. We need a module-level reference to the HTTP client. The state schema has `active_remote_id` at the top level (not per-device) — this tells us which remote the user is currently viewing. Each device entry has `viewing_session` and `view_mode`. + +**Step 1: Write the failing test** + +In `muxplex/tests/test_api.py`, add at the end of the file. This test verifies the poll cycle fires a bell/clear to the remote when a device is viewing a remote session: + +```python +def test_poll_cycle_fires_federation_bell_clear_for_remote_session( + monkeypatch, tmp_path +): + """_run_poll_cycle must fire POST bell/clear to the remote server when a device + is viewing a remote session in fullscreen with recent interaction. + + This is the heartbeat-driven bell clearing that makes activity indicators + clear correctly across federation boundaries. + """ + import asyncio + import time + import httpx + from unittest.mock import AsyncMock, MagicMock, patch + + import muxplex.main as main_mod + import muxplex.state as state_mod + import muxplex.settings as settings_mod + + # Redirect state and settings to tmp_path + tmp_state_dir = tmp_path / "state" + tmp_state_path = tmp_state_dir / "state.json" + monkeypatch.setattr(state_mod, "STATE_DIR", tmp_state_dir) + monkeypatch.setattr(state_mod, "STATE_PATH", tmp_state_path) + + fake_settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_settings_path) + + # Set up settings with one remote instance + settings_mod.save_settings({ + "remote_instances": [ + {"url": "http://server-b:8088", "name": "Server B", "key": "test-key"} + ], + "device_name": "Server A", + }) + + # Set up state: one device viewing a remote session in fullscreen + state = state_mod.empty_state() + state["active_remote_id"] = 0 # viewing a session on remote index 0 + state["devices"]["d-12345678"] = { + "label": "Browser", + "viewing_session": "build", + "view_mode": "fullscreen", + "last_interaction_at": time.time(), + "last_heartbeat_at": time.time(), + } + # The local session list has a bell for "build" on the remote + # (the poll cycle won't find it locally, but state has the device info) + state_mod.save_state(state) + + # Mock the federation client + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"ok": True} + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_client.post = AsyncMock(return_value=mock_response) + monkeypatch.setattr(main_mod, "_federation_client", mock_client) + + # Mock enumerate_sessions and snapshot_all to return empty (no local sessions) + monkeypatch.setattr(main_mod, "enumerate_sessions", AsyncMock(return_value=[])) + monkeypatch.setattr(main_mod, "snapshot_all", AsyncMock(return_value={})) + monkeypatch.setattr(main_mod, "update_session_cache", MagicMock()) + monkeypatch.setattr(main_mod, "process_bell_flags", AsyncMock(return_value=False)) + + # Run one poll cycle + asyncio.get_event_loop().run_until_complete(main_mod._run_poll_cycle()) + + # Verify bell/clear was fired to the remote + mock_client.post.assert_called_once() + call_args = mock_client.post.call_args + assert "/api/sessions/build/bell/clear" in call_args[0][0], ( + f"Expected bell/clear URL for 'build' session, got: {call_args[0][0]}" + ) + assert call_args[1]["headers"]["Authorization"] == "Bearer test-key", ( + "Bell/clear request must include Bearer auth header" + ) +``` + +**Step 2: Run test to verify it fails** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_poll_cycle_fires_federation_bell_clear_for_remote_session -v` +Expected: FAIL — `_federation_client` doesn't exist yet. + +**Step 3: Write minimal implementation** + +**3a.** In `muxplex/main.py`, after the `_poll_task` variable (around line 83), add: + +```python +_federation_client: httpx.AsyncClient | None = None +``` + +**3b.** In `muxplex/main.py`, in the `lifespan()` function, after `app.state.federation_client = httpx.AsyncClient(...)` (around line 189), add: + +```python + global _federation_client + _federation_client = app.state.federation_client +``` + +And in the shutdown section, after `await client.aclose()` (around line 196), add: + +```python + _federation_client = None +``` + +**3c.** In `muxplex/main.py`, in `_run_poll_cycle()`, after the `apply_bell_clear_rule(state)` line (line 132) and before `prune_devices(state)` (line 135), add step 12: + +```python + # 12. Fire bell/clear to remote servers for federated sessions being viewed + # This handles the case where a browser on Server A is viewing a remote + # session on Server B — Server B doesn't know anyone is watching, so its + # should_clear_bell() never fires. We fix that by proactively clearing + # from the poll cycle. Fire-and-forget: errors are logged and ignored. + if _federation_client is not None: + active_remote = state.get("active_remote_id") + if active_remote is not None: + settings = load_settings() + remotes = settings.get("remote_instances", []) + cutoff = time.time() - 60.0 # same window as should_clear_bell + for device in state["devices"].values(): + if ( + device["viewing_session"] + and device["view_mode"] == "fullscreen" + and device["last_interaction_at"] > cutoff + ): + session_name = device["viewing_session"] + try: + remote_idx = int(active_remote) + if 0 <= remote_idx < len(remotes): + remote = remotes[remote_idx] + remote_url = remote.get("url", "").rstrip("/") + remote_key = remote.get("key", "") + url = f"{remote_url}/api/sessions/{session_name}/bell/clear" + headers = ( + {"Authorization": f"Bearer {remote_key}"} + if remote_key + else {} + ) + await _federation_client.post(url, headers=headers) + except Exception as exc: + _log.debug( + "federation bell/clear for %s failed: %s", + session_name, + exc, + ) +``` + +Note: We need to release the `state_lock` before making the HTTP call to avoid deadlocks (the remote's response isn't needed for local state). However, since the existing poll cycle is fully under `state_lock`, and this is a fire-and-forget call with a 5-second timeout, keep it inside the lock for now. The httpx call is async and won't block the event loop. + +Actually, re-reading the `_run_poll_cycle` function, the entire thing runs under `async with state_lock`. The HTTP call should work fine since it's async (non-blocking). The 5-second timeout on the httpx client protects against slow remotes. + +**Step 4: Run test to verify it passes** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_poll_cycle_fires_federation_bell_clear_for_remote_session -v` +Expected: PASS + +**Step 5: Run full test suite** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/ -v --timeout=30 2>&1 | tail -5` +Expected: All tests pass. + +**Step 6: Commit** + +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: heartbeat-driven bell clearing for remote sessions in poll cycle" +``` + +--- + +### Task 4: Phase 1 verification and commit + +**Files:** +- No new files — verification only. + +**Step 1: Run full Python test suite** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/ -v --timeout=60 2>&1 | tail -10` +Expected: All tests pass. + +**Step 2: Run full JS test suite** + +Run: `cd muxplex && cd muxplex && node --test frontend/tests/test_app.mjs 2>&1 | tail -5` +Expected: All tests pass. + +Run: `cd muxplex && cd muxplex && node --test frontend/tests/test_terminal.mjs 2>&1 | tail -5` +Expected: All tests pass. + +**Step 3: Run grep verification checklist** + +Run: `cd muxplex && grep -n '!s\.remoteId' muxplex/frontend/app.js` +Expected: Zero matches (the falsy-0 bug is fixed). + +Run: `cd muxplex && grep -n 'getVisibleSessions' muxplex/frontend/app.js | grep -E 'updatePageTitle|updateFaviconBadge'` +Expected: No matches from grep directly, but the function bodies should contain `getVisibleSessions`. Verify with: + +Run: `cd muxplex && python3 -c "import re; js=open('muxplex/frontend/app.js').read(); m1=re.search(r'function updatePageTitle.*?\n}', js, re.DOTALL); m2=re.search(r'function updateFaviconBadge.*?\n}', js, re.DOTALL); print('updatePageTitle has getVisibleSessions:', 'getVisibleSessions' in m1.group()); print('updateFaviconBadge has getVisibleSessions:', 'getVisibleSessions' in m2.group())"` +Expected: Both `True`. + +Run: `cd muxplex && grep -n '_federation_client' muxplex/main.py` +Expected: Multiple matches (declaration, assignment in lifespan, usage in poll cycle, cleanup). + +**Step 4: Tag Phase 1 complete** + +No additional commit needed — all Phase 1 changes are already committed in Tasks 1-3. + +--- + +## Phase 2: Federation Settings Sync + +### Task 5: Add `SYNCABLE_KEYS` allowlist and `settings_updated_at` to `settings.py` + +**Files:** +- Modify: `muxplex/settings.py:16-44` (add constant and new default key) +- Test: `muxplex/tests/test_settings.py` (add tests) + +**Step 1: Write the failing tests** + +In `muxplex/tests/test_settings.py`, add at the end: + +```python +# ============================================================ +# SYNCABLE_KEYS and settings_updated_at (federation settings sync) +# ============================================================ + + +def test_syncable_keys_exists_and_is_a_set(): + """SYNCABLE_KEYS must be a set constant exported from settings.py.""" + from muxplex.settings import SYNCABLE_KEYS + + assert isinstance(SYNCABLE_KEYS, (set, frozenset)), ( + f"SYNCABLE_KEYS must be a set, got: {type(SYNCABLE_KEYS).__name__}" + ) + + +def test_syncable_keys_contains_expected_keys(): + """SYNCABLE_KEYS must contain all 15 user-experience preference keys.""" + from muxplex.settings import SYNCABLE_KEYS + + expected = { + "fontSize", "hoverPreviewDelay", "gridColumns", "bellSound", + "viewMode", "showDeviceBadges", "showHoverPreview", "activityIndicator", + "gridViewMode", "sidebarOpen", "sort_order", "hidden_sessions", + "default_session", "window_size_largest", "auto_open_created", + } + assert SYNCABLE_KEYS == expected, ( + f"SYNCABLE_KEYS mismatch.\n" + f" Missing: {expected - SYNCABLE_KEYS}\n" + f" Extra: {SYNCABLE_KEYS - expected}" + ) + + +def test_syncable_keys_excludes_local_only_keys(): + """SYNCABLE_KEYS must NOT contain any per-machine identity/infra keys.""" + from muxplex.settings import SYNCABLE_KEYS + + local_only = { + "host", "port", "auth", "session_ttl", "tls_cert", "tls_key", + "device_name", "federation_key", "remote_instances", + "multi_device_enabled", "new_session_template", "delete_session_template", + } + overlap = SYNCABLE_KEYS & local_only + assert not overlap, ( + f"SYNCABLE_KEYS must not contain local-only keys: {overlap}" + ) + + +def test_syncable_keys_are_subset_of_default_settings(): + """Every key in SYNCABLE_KEYS must exist in DEFAULT_SETTINGS.""" + from muxplex.settings import SYNCABLE_KEYS + + missing = SYNCABLE_KEYS - set(DEFAULT_SETTINGS.keys()) + assert not missing, ( + f"SYNCABLE_KEYS contains keys not in DEFAULT_SETTINGS: {missing}" + ) + + +def test_defaults_include_settings_updated_at(): + """DEFAULT_SETTINGS must include 'settings_updated_at' with default 0.0.""" + assert "settings_updated_at" in DEFAULT_SETTINGS, ( + "DEFAULT_SETTINGS must include 'settings_updated_at'" + ) + assert DEFAULT_SETTINGS["settings_updated_at"] == 0.0, ( + f"settings_updated_at default must be 0.0, got: {DEFAULT_SETTINGS['settings_updated_at']!r}" + ) + + +def test_settings_updated_at_not_in_syncable_keys(): + """settings_updated_at is a metadata field, not a user setting — must NOT be in SYNCABLE_KEYS.""" + from muxplex.settings import SYNCABLE_KEYS + + assert "settings_updated_at" not in SYNCABLE_KEYS, ( + "settings_updated_at must NOT be in SYNCABLE_KEYS — it's sync metadata, not a user setting" + ) +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_settings.py::test_syncable_keys_exists_and_is_a_set muxplex/tests/test_settings.py::test_defaults_include_settings_updated_at -v` +Expected: Both FAIL — neither `SYNCABLE_KEYS` nor `settings_updated_at` exist yet. + +**Step 3: Write minimal implementation** + +In `muxplex/settings.py`, after the imports (after line 11), add the `SYNCABLE_KEYS` constant: + +```python +# Keys that sync across federated servers (user-experience preferences). +# Per-machine identity/infra keys (host, port, auth, etc.) never sync. +SYNCABLE_KEYS: frozenset[str] = frozenset({ + "fontSize", + "hoverPreviewDelay", + "gridColumns", + "bellSound", + "viewMode", + "showDeviceBadges", + "showHoverPreview", + "activityIndicator", + "gridViewMode", + "sidebarOpen", + "sort_order", + "hidden_sessions", + "default_session", + "window_size_largest", + "auto_open_created", +}) +``` + +In `muxplex/settings.py`, add `settings_updated_at` to `DEFAULT_SETTINGS` (after `sidebarOpen`): + +```python + "settings_updated_at": 0.0, +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_settings.py -v --timeout=10 2>&1 | tail -10` +Expected: All tests pass (including the 7 new ones). + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: add SYNCABLE_KEYS allowlist and settings_updated_at to DEFAULT_SETTINGS" +``` + +--- + +### Task 6: Add sync-aware `patch_settings()` timestamp bumping and `apply_synced_settings()` + +**Files:** +- Modify: `muxplex/settings.py:81-132` (update `patch_settings`, add `apply_synced_settings`) +- Test: `muxplex/tests/test_settings.py` (add tests) + +**Step 1: Write the failing tests** + +In `muxplex/tests/test_settings.py`, add at the end: + +```python +def test_patch_settings_bumps_settings_updated_at_for_syncable_key(): + """patch_settings() must bump settings_updated_at when a syncable key is patched.""" + import time + + before = time.time() + result = patch_settings({"fontSize": 20}) + after = time.time() + assert before <= result["settings_updated_at"] <= after, ( + f"settings_updated_at must be bumped to ~now, got: {result['settings_updated_at']}" + ) + + +def test_patch_settings_does_not_bump_settings_updated_at_for_local_only_key(): + """patch_settings() must NOT bump settings_updated_at when only local-only keys are patched.""" + # First, set a known timestamp + save_settings({"settings_updated_at": 100.0}) + result = patch_settings({"host": "0.0.0.0"}) + assert result["settings_updated_at"] == 100.0, ( + f"settings_updated_at must NOT be bumped for local-only key 'host', " + f"got: {result['settings_updated_at']}" + ) + + +def test_apply_synced_settings_exists(): + """apply_synced_settings must be importable from settings.py.""" + from muxplex.settings import apply_synced_settings + + assert callable(apply_synced_settings) + + +def test_apply_synced_settings_applies_syncable_keys_only(): + """apply_synced_settings must apply only syncable keys and ignore local-only keys.""" + from muxplex.settings import apply_synced_settings + + save_settings({"host": "127.0.0.1", "fontSize": 14}) + apply_synced_settings( + {"fontSize": 22, "host": "HACKED", "sort_order": "alpha"}, + timestamp=500.0, + ) + loaded = load_settings() + assert loaded["fontSize"] == 22, "Syncable key 'fontSize' must be applied" + assert loaded["sort_order"] == "alpha", "Syncable key 'sort_order' must be applied" + assert loaded["host"] == "127.0.0.1", ( + "Local-only key 'host' must NOT be overwritten by sync" + ) + + +def test_apply_synced_settings_uses_incoming_timestamp(): + """apply_synced_settings must set settings_updated_at to the INCOMING timestamp (not time.time()). + + This is critical for sync loop prevention — if we used time.time(), the local + timestamp would always be slightly newer than the remote, causing an infinite + push-pull loop. + """ + from muxplex.settings import apply_synced_settings + + apply_synced_settings({"fontSize": 18}, timestamp=12345.678) + loaded = load_settings() + assert loaded["settings_updated_at"] == 12345.678, ( + f"settings_updated_at must be the incoming timestamp 12345.678, " + f"got: {loaded['settings_updated_at']}" + ) + + +def test_apply_synced_settings_ignores_unknown_keys(): + """apply_synced_settings must silently ignore keys not in SYNCABLE_KEYS.""" + from muxplex.settings import apply_synced_settings + + apply_synced_settings( + {"fontSize": 16, "totally_unknown_key": "should_be_ignored"}, + timestamp=999.0, + ) + loaded = load_settings() + assert loaded["fontSize"] == 16 + assert "totally_unknown_key" not in loaded +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_settings.py::test_patch_settings_bumps_settings_updated_at_for_syncable_key muxplex/tests/test_settings.py::test_apply_synced_settings_exists -v` +Expected: Both FAIL. + +**Step 3: Write minimal implementation** + +In `muxplex/settings.py`, add `import time` to the imports (it's not currently imported). Add after `import socket`: + +```python +import time +``` + +In `muxplex/settings.py`, modify `patch_settings()`. After the line `for key in DEFAULT_SETTINGS:` loop that applies the patch (around line 109-111), and before the `if "remote_instances" in patch:` block (around line 114), add: + +```python + # Bump settings_updated_at if any syncable key was patched + if any(key in SYNCABLE_KEYS for key in patch if key in DEFAULT_SETTINGS): + current["settings_updated_at"] = time.time() +``` + +In `muxplex/settings.py`, add the new function after `patch_settings()` (before `load_federation_key()`): + +```python +def apply_synced_settings(settings: dict, timestamp: float) -> dict: + """Apply synced settings from a remote server. + + Only keys in SYNCABLE_KEYS are applied. The settings_updated_at is set to + the incoming *timestamp* (NOT time.time()) to prevent sync loops — both + servers end up with the same timestamp so the next poll sees them as equal. + + Unknown keys (not in SYNCABLE_KEYS) are silently ignored. + """ + current = load_settings() + for key in SYNCABLE_KEYS: + if key in settings: + current[key] = settings[key] + current["settings_updated_at"] = timestamp + save_settings(current) + return current +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_settings.py -v --timeout=10 2>&1 | tail -15` +Expected: All tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/settings.py muxplex/tests/test_settings.py && git commit -m "feat: sync-aware patch_settings timestamp bumping and apply_synced_settings" +``` + +--- + +### Task 7: Add `GET /api/settings/sync` endpoint + +**Files:** +- Modify: `muxplex/main.py` (add endpoint after existing settings endpoints, around line 676) +- Modify: `muxplex/main.py:67` (add `SYNCABLE_KEYS` to import) +- Test: `muxplex/tests/test_api.py` (add tests) + +**Step 1: Write the failing tests** + +In `muxplex/tests/test_api.py`, add at the end: + +```python +# ============================================================ +# Federation settings sync endpoints +# ============================================================ + + +def test_get_settings_sync_returns_syncable_keys_only(client, monkeypatch, tmp_path): + """GET /api/settings/sync must return only syncable keys + settings_updated_at.""" + import json + import muxplex.settings as settings_mod + + fake_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path) + settings_mod.save_settings({ + "fontSize": 18, + "host": "0.0.0.0", + "settings_updated_at": 1234.5, + }) + + resp = client.get("/api/settings/sync") + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" + data = resp.json() + + # Must include syncable keys + assert "settings" in data, "Response must have 'settings' key" + assert "settings_updated_at" in data, "Response must have 'settings_updated_at' key" + assert data["settings"]["fontSize"] == 18 + assert data["settings_updated_at"] == 1234.5 + + # Must NOT include local-only keys + assert "host" not in data["settings"], ( + "Sync response must NOT include local-only key 'host'" + ) + assert "port" not in data["settings"], ( + "Sync response must NOT include local-only key 'port'" + ) + assert "federation_key" not in data["settings"], ( + "Sync response must NOT include 'federation_key'" + ) + + +def test_get_settings_sync_requires_bearer_auth(monkeypatch, tmp_path): + """GET /api/settings/sync must require Bearer token auth (like other federation endpoints).""" + # Create a client with a non-localhost IP to test auth enforcement + import muxplex.settings as settings_mod + + fake_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path) + + # The existing `client` fixture sets a session cookie which bypasses auth. + # For this test we need to verify the endpoint is accessible via Bearer token. + # Since the test client uses localhost, auth is bypassed anyway. + # The pattern test below verifies the endpoint exists and returns correct shape. + # Auth enforcement is already tested by test_federation_bearer_auth_accepted. + pass # Auth enforcement tested via existing auth middleware tests +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_get_settings_sync_returns_syncable_keys_only -v` +Expected: FAIL — 404 because the endpoint doesn't exist. + +**Step 3: Write minimal implementation** + +In `muxplex/main.py`, update the import from `muxplex.settings` (line 67) to include `SYNCABLE_KEYS` and `apply_synced_settings`: + +```python +from muxplex.settings import SYNCABLE_KEYS, apply_synced_settings, load_federation_key, load_settings, patch_settings +``` + +In `muxplex/main.py`, after the `PATCH /api/settings` endpoint (after line 675), add: + +```python +@app.get("/api/settings/sync") +async def get_settings_sync() -> dict: + """Return syncable settings + timestamp for federation sync protocol. + + Returns only keys in SYNCABLE_KEYS (no local-only infra keys, no secrets). + Used by remote servers to compare timestamps and decide sync direction. + Authenticated via federation Bearer token (same as other federation endpoints). + """ + settings = load_settings() + syncable = {key: settings[key] for key in SYNCABLE_KEYS if key in settings} + return { + "settings": syncable, + "settings_updated_at": settings.get("settings_updated_at", 0.0), + } +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_get_settings_sync_returns_syncable_keys_only -v` +Expected: PASS + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add GET /api/settings/sync endpoint for federation sync protocol" +``` + +--- + +### Task 8: Add `PUT /api/settings/sync` endpoint + +**Files:** +- Modify: `muxplex/main.py` (add endpoint after the GET sync endpoint) +- Test: `muxplex/tests/test_api.py` (add tests) + +**Step 1: Write the failing tests** + +In `muxplex/tests/test_api.py`, add: + +```python +def test_put_settings_sync_applies_when_incoming_is_newer(client, monkeypatch, tmp_path): + """PUT /api/settings/sync must apply incoming settings when incoming timestamp is newer.""" + import json + import muxplex.settings as settings_mod + + fake_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path) + settings_mod.save_settings({"fontSize": 14, "settings_updated_at": 100.0}) + + resp = client.put( + "/api/settings/sync", + json={ + "settings": {"fontSize": 22, "sort_order": "alpha"}, + "settings_updated_at": 200.0, + }, + ) + assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}" + + # Verify settings were applied + loaded = settings_mod.load_settings() + assert loaded["fontSize"] == 22, "fontSize must be updated to 22" + assert loaded["sort_order"] == "alpha", "sort_order must be updated to 'alpha'" + assert loaded["settings_updated_at"] == 200.0, ( + "settings_updated_at must be set to the incoming timestamp (200.0)" + ) + + +def test_put_settings_sync_rejects_when_local_is_newer(client, monkeypatch, tmp_path): + """PUT /api/settings/sync must return 409 when local timestamp is newer than incoming.""" + import json + import muxplex.settings as settings_mod + + fake_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path) + settings_mod.save_settings({"fontSize": 18, "settings_updated_at": 300.0}) + + resp = client.put( + "/api/settings/sync", + json={ + "settings": {"fontSize": 12}, + "settings_updated_at": 100.0, + }, + ) + assert resp.status_code == 409, f"Expected 409, got {resp.status_code}: {resp.text}" + + # Verify settings were NOT changed + loaded = settings_mod.load_settings() + assert loaded["fontSize"] == 18, "fontSize must NOT be changed when local is newer" + assert loaded["settings_updated_at"] == 300.0, "Timestamp must NOT change" + + # Response should include local state so caller can adopt it + data = resp.json() + assert "settings" in data, "409 response must include local settings" + assert "settings_updated_at" in data, "409 response must include local timestamp" + + +def test_put_settings_sync_ignores_local_only_keys_in_payload(client, monkeypatch, tmp_path): + """PUT /api/settings/sync must ignore local-only keys in the incoming payload.""" + import json + import muxplex.settings as settings_mod + + fake_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path) + settings_mod.save_settings({ + "host": "127.0.0.1", + "fontSize": 14, + "settings_updated_at": 50.0, + }) + + resp = client.put( + "/api/settings/sync", + json={ + "settings": {"fontSize": 20, "host": "HACKED"}, + "settings_updated_at": 200.0, + }, + ) + assert resp.status_code == 200 + + loaded = settings_mod.load_settings() + assert loaded["fontSize"] == 20, "Syncable key 'fontSize' must be updated" + assert loaded["host"] == "127.0.0.1", ( + "Local-only key 'host' must NOT be overwritten by sync" + ) + + +def test_put_settings_sync_noop_when_timestamps_equal(client, monkeypatch, tmp_path): + """PUT /api/settings/sync is a no-op when timestamps are equal.""" + import json + import muxplex.settings as settings_mod + + fake_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_path) + settings_mod.save_settings({"fontSize": 14, "settings_updated_at": 200.0}) + + resp = client.put( + "/api/settings/sync", + json={ + "settings": {"fontSize": 22}, + "settings_updated_at": 200.0, + }, + ) + # Equal timestamps = no-op, return 200 + assert resp.status_code == 200 + + loaded = settings_mod.load_settings() + assert loaded["fontSize"] == 14, "fontSize must NOT change when timestamps are equal" +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_put_settings_sync_applies_when_incoming_is_newer muxplex/tests/test_api.py::test_put_settings_sync_rejects_when_local_is_newer -v` +Expected: Both FAIL — 405 Method Not Allowed (endpoint doesn't exist). + +**Step 3: Write minimal implementation** + +In `muxplex/main.py`, after the `GET /api/settings/sync` endpoint, add: + +```python +@app.put("/api/settings/sync") +async def put_settings_sync(request: Request) -> dict: + """Accept synced settings from a remote server (federation sync protocol). + + Compares incoming settings_updated_at with local. If incoming is strictly + newer, applies the syncable keys via apply_synced_settings() and returns + the updated state. If local is newer or equal, returns 409 with local state + so the caller knows to adopt from us instead. + + Authenticated via federation Bearer token. + """ + body = await request.json() + incoming_settings = body.get("settings", {}) + incoming_ts = body.get("settings_updated_at", 0.0) + + local_settings = load_settings() + local_ts = local_settings.get("settings_updated_at", 0.0) + + if incoming_ts > local_ts: + # Incoming is newer — apply it + updated = apply_synced_settings(incoming_settings, incoming_ts) + syncable = {key: updated[key] for key in SYNCABLE_KEYS if key in updated} + return { + "settings": syncable, + "settings_updated_at": updated.get("settings_updated_at", 0.0), + "applied": True, + } + else: + # Local is newer or equal — reject, return local state + syncable = {key: local_settings[key] for key in SYNCABLE_KEYS if key in local_settings} + from starlette.responses import JSONResponse + return JSONResponse( + status_code=409, + content={ + "settings": syncable, + "settings_updated_at": local_ts, + "applied": False, + }, + ) +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_api.py -k "settings_sync" -v` +Expected: All 5 sync tests pass. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add PUT /api/settings/sync endpoint with newer-wins conflict resolution" +``` + +--- + +### Task 9: Add sync logic to `_run_poll_cycle` + +**Files:** +- Modify: `muxplex/main.py:91-138` (add settings sync step to poll cycle) +- Test: `muxplex/tests/test_api.py` (add test) + +**Important:** Settings sync should NOT run every 2 seconds — that's too aggressive. Use a counter to sync every 15 poll cycles (~30 seconds). Bell clearing still runs every 2 seconds. + +**Step 1: Write the failing test** + +In `muxplex/tests/test_api.py`, add: + +```python +def test_poll_cycle_syncs_settings_when_remote_is_newer(monkeypatch, tmp_path): + """_run_poll_cycle must adopt remote settings when the remote's timestamp is newer. + + The sync check runs every SETTINGS_SYNC_INTERVAL poll cycles (not every cycle). + """ + import asyncio + import time + import httpx + from unittest.mock import AsyncMock, MagicMock + + import muxplex.main as main_mod + import muxplex.state as state_mod + import muxplex.settings as settings_mod + + # Redirect state and settings to tmp_path + tmp_state_dir = tmp_path / "state" + tmp_state_path = tmp_state_dir / "state.json" + monkeypatch.setattr(state_mod, "STATE_DIR", tmp_state_dir) + monkeypatch.setattr(state_mod, "STATE_PATH", tmp_state_path) + + fake_settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_settings_path) + + # Local settings: fontSize 14, timestamp 100 + settings_mod.save_settings({ + "remote_instances": [ + {"url": "http://server-b:8088", "name": "Server B", "key": "test-key"} + ], + "device_name": "Server A", + "fontSize": 14, + "settings_updated_at": 100.0, + }) + + # Set up empty state (no devices, no local sessions) + state_mod.save_state(state_mod.empty_state()) + + # Mock the federation client to return newer settings from remote + remote_sync_response = MagicMock() + remote_sync_response.status_code = 200 + remote_sync_response.json.return_value = { + "settings": {"fontSize": 22, "sort_order": "alpha"}, + "settings_updated_at": 200.0, + } + remote_sync_response.raise_for_status = MagicMock() + + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_client.get = AsyncMock(return_value=remote_sync_response) + mock_client.post = AsyncMock() # for bell/clear (not used here) + monkeypatch.setattr(main_mod, "_federation_client", mock_client) + + # Force the sync counter to trigger on this cycle + monkeypatch.setattr(main_mod, "_settings_sync_counter", main_mod.SETTINGS_SYNC_INTERVAL - 1) + + # Mock enumerate_sessions and snapshot_all + monkeypatch.setattr(main_mod, "enumerate_sessions", AsyncMock(return_value=[])) + monkeypatch.setattr(main_mod, "snapshot_all", AsyncMock(return_value={})) + monkeypatch.setattr(main_mod, "update_session_cache", MagicMock()) + monkeypatch.setattr(main_mod, "process_bell_flags", AsyncMock(return_value=False)) + + # Run one poll cycle + asyncio.get_event_loop().run_until_complete(main_mod._run_poll_cycle()) + + # Verify settings sync GET was called + get_calls = [c for c in mock_client.get.call_args_list + if "/api/settings/sync" in str(c)] + assert len(get_calls) == 1, ( + f"Expected 1 GET /api/settings/sync call, got {len(get_calls)}" + ) + + # Verify local settings were updated + loaded = settings_mod.load_settings() + assert loaded["fontSize"] == 22, ( + f"Local fontSize must be updated to 22 from remote, got: {loaded['fontSize']}" + ) + assert loaded["settings_updated_at"] == 200.0, ( + f"Local timestamp must be set to remote's 200.0, got: {loaded['settings_updated_at']}" + ) + + +def test_poll_cycle_pushes_settings_when_local_is_newer(monkeypatch, tmp_path): + """_run_poll_cycle must push settings to remote when local timestamp is newer.""" + import asyncio + import time + import httpx + from unittest.mock import AsyncMock, MagicMock + + import muxplex.main as main_mod + import muxplex.state as state_mod + import muxplex.settings as settings_mod + + # Redirect state and settings to tmp_path + tmp_state_dir = tmp_path / "state" + tmp_state_path = tmp_state_dir / "state.json" + monkeypatch.setattr(state_mod, "STATE_DIR", tmp_state_dir) + monkeypatch.setattr(state_mod, "STATE_PATH", tmp_state_path) + + fake_settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", fake_settings_path) + + # Local settings: fontSize 22, timestamp 300 (NEWER than remote) + settings_mod.save_settings({ + "remote_instances": [ + {"url": "http://server-b:8088", "name": "Server B", "key": "test-key"} + ], + "device_name": "Server A", + "fontSize": 22, + "settings_updated_at": 300.0, + }) + + state_mod.save_state(state_mod.empty_state()) + + # Remote returns older timestamp + remote_get_response = MagicMock() + remote_get_response.status_code = 200 + remote_get_response.json.return_value = { + "settings": {"fontSize": 14}, + "settings_updated_at": 100.0, + } + remote_get_response.raise_for_status = MagicMock() + + remote_put_response = MagicMock() + remote_put_response.status_code = 200 + remote_put_response.json.return_value = {"applied": True} + remote_put_response.raise_for_status = MagicMock() + + mock_client = AsyncMock(spec=httpx.AsyncClient) + mock_client.get = AsyncMock(return_value=remote_get_response) + mock_client.put = AsyncMock(return_value=remote_put_response) + mock_client.post = AsyncMock() + monkeypatch.setattr(main_mod, "_federation_client", mock_client) + monkeypatch.setattr(main_mod, "_settings_sync_counter", main_mod.SETTINGS_SYNC_INTERVAL - 1) + + monkeypatch.setattr(main_mod, "enumerate_sessions", AsyncMock(return_value=[])) + monkeypatch.setattr(main_mod, "snapshot_all", AsyncMock(return_value={})) + monkeypatch.setattr(main_mod, "update_session_cache", MagicMock()) + monkeypatch.setattr(main_mod, "process_bell_flags", AsyncMock(return_value=False)) + + asyncio.get_event_loop().run_until_complete(main_mod._run_poll_cycle()) + + # Verify PUT was called to push settings to remote + put_calls = [c for c in mock_client.put.call_args_list + if "/api/settings/sync" in str(c)] + assert len(put_calls) == 1, ( + f"Expected 1 PUT /api/settings/sync call, got {len(put_calls)}" + ) + # Verify the PUT payload includes local settings + put_kwargs = put_calls[0][1] if put_calls[0][1] else {} + if "json" in put_kwargs: + assert put_kwargs["json"]["settings_updated_at"] == 300.0 +``` + +**Step 2: Run tests to verify they fail** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_poll_cycle_syncs_settings_when_remote_is_newer -v` +Expected: FAIL — `SETTINGS_SYNC_INTERVAL` and `_settings_sync_counter` don't exist. + +**Step 3: Write minimal implementation** + +In `muxplex/main.py`, after `POLL_INTERVAL` and `SERVER_PORT` (around line 75), add: + +```python +SETTINGS_SYNC_INTERVAL: int = 15 # sync settings every 15 poll cycles (~30 seconds) +_settings_sync_counter: int = 0 +``` + +In `muxplex/main.py`, in `_run_poll_cycle()`, after the federation bell-clear block (the step 12 you added in Task 3) and before `prune_devices(state)`, add: + +```python + # 13. Federation settings sync (every SETTINGS_SYNC_INTERVAL cycles) + global _settings_sync_counter + _settings_sync_counter += 1 + if ( + _federation_client is not None + and _settings_sync_counter >= SETTINGS_SYNC_INTERVAL + ): + _settings_sync_counter = 0 + sync_settings = load_settings() + remotes = sync_settings.get("remote_instances", []) + local_ts = sync_settings.get("settings_updated_at", 0.0) + + for i, remote in enumerate(remotes): + remote_url = remote.get("url", "").rstrip("/") + remote_key = remote.get("key", "") + headers = ( + {"Authorization": f"Bearer {remote_key}"} + if remote_key + else {} + ) + try: + # GET remote's sync state + resp = await _federation_client.get( + f"{remote_url}/api/settings/sync", + headers=headers, + ) + if resp.status_code in (404, 405): + # Remote is an older muxplex without sync support — skip + continue + resp.raise_for_status() + remote_data = resp.json() + remote_ts = remote_data.get("settings_updated_at", 0.0) + + if remote_ts > local_ts: + # Remote is newer — adopt its settings + apply_synced_settings( + remote_data.get("settings", {}), + remote_ts, + ) + # Update local_ts so subsequent remotes compare against + # the newly adopted timestamp + local_ts = remote_ts + _log.info( + "settings sync: adopted from %s (ts=%.1f)", + remote_url, remote_ts, + ) + elif local_ts > remote_ts: + # Local is newer — push to remote + syncable = { + key: sync_settings[key] + for key in SYNCABLE_KEYS + if key in sync_settings + } + await _federation_client.put( + f"{remote_url}/api/settings/sync", + json={ + "settings": syncable, + "settings_updated_at": local_ts, + }, + headers=headers, + ) + _log.info( + "settings sync: pushed to %s (ts=%.1f)", + remote_url, local_ts, + ) + # If equal, no action needed + except Exception as exc: + _log.debug( + "settings sync with %s failed: %s", remote_url, exc + ) +``` + +**Step 4: Run tests to verify they pass** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/test_api.py::test_poll_cycle_syncs_settings_when_remote_is_newer muxplex/tests/test_api.py::test_poll_cycle_pushes_settings_when_local_is_newer -v` +Expected: Both PASS. + +**Step 5: Commit** + +```bash +cd muxplex && git add muxplex/main.py muxplex/tests/test_api.py && git commit -m "feat: add P2P settings sync logic to poll cycle with 30-second interval" +``` + +--- + +### Task 10: Phase 2 verification and full test suite + +**Files:** +- No new files — verification only. + +**Step 1: Run full Python test suite** + +Run: `cd muxplex && python3 -m pytest muxplex/tests/ -v --timeout=60 2>&1 | tail -15` +Expected: All tests pass. + +**Step 2: Run full JS test suite** + +Run: `cd muxplex && cd muxplex && node --test frontend/tests/test_app.mjs 2>&1 | tail -5` +Expected: All tests pass. + +Run: `cd muxplex && cd muxplex && node --test frontend/tests/test_terminal.mjs 2>&1 | tail -5` +Expected: All tests pass. + +**Step 3: Run code quality checks** + +Run: `cd muxplex && python3 -m ruff check muxplex/settings.py muxplex/main.py` +Expected: No errors. + +Run: `cd muxplex && python3 -m ruff format --check muxplex/settings.py muxplex/main.py` +Expected: No formatting issues. + +**Step 4: Verify grep checklist** + +Run: `cd muxplex && grep -n 'SYNCABLE_KEYS' muxplex/settings.py` +Expected: Definition and usage in `apply_synced_settings`. + +Run: `cd muxplex && grep -n 'settings_updated_at' muxplex/settings.py` +Expected: In `DEFAULT_SETTINGS`, `patch_settings`, and `apply_synced_settings`. + +Run: `cd muxplex && grep -n '/api/settings/sync' muxplex/main.py` +Expected: Two endpoints (GET and PUT) plus poll cycle usage. + +Run: `cd muxplex && grep -n '_federation_client' muxplex/main.py` +Expected: Module-level declaration, lifespan assignment/cleanup, poll cycle usage (bell-clear and settings sync). + +Run: `cd muxplex && grep -n 'SETTINGS_SYNC_INTERVAL\|_settings_sync_counter' muxplex/main.py` +Expected: Constant definition, counter definition, counter logic in poll cycle. + +**Step 5: Verify the JS bug fix is clean** + +Run: `cd muxplex && grep -n '!s\.remoteId' muxplex/frontend/app.js` +Expected: Zero matches. + +Run: `cd muxplex && python3 -c " +import re +js = open('muxplex/frontend/app.js').read() +fns = ['updatePageTitle', 'updateFaviconBadge'] +for fn in fns: + m = re.search(rf'function {fn}\b[^{{]*\{{(.*?)\n\}}', js, re.DOTALL) + has = 'getVisibleSessions' in m.group(1) if m else False + print(f'{fn} uses getVisibleSessions: {has}') +"` +Expected: Both `True`. + +**Step 6: Final commit (if any test fixes were needed)** + +If all tests pass, no additional commit is needed. If minor fixes were required, commit with: + +```bash +cd muxplex && git add -A && git commit -m "fix: Phase 2 verification cleanup" +``` + +**Step 7: Review commit log** + +Run: `cd muxplex && git log --oneline -10` +Expected: Clean commit history showing all 10 tasks: +``` +fix: getVisibleSessions falsy-0 bug +fix: updatePageTitle and updateFaviconBadge now filter hidden sessions +feat: heartbeat-driven bell clearing for remote sessions in poll cycle +feat: add SYNCABLE_KEYS allowlist and settings_updated_at to DEFAULT_SETTINGS +feat: sync-aware patch_settings timestamp bumping and apply_synced_settings +feat: add GET /api/settings/sync endpoint for federation sync protocol +feat: add PUT /api/settings/sync endpoint with newer-wins conflict resolution +feat: add P2P settings sync logic to poll cycle with 30-second interval +``` diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 5a503b2..f3451dd 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -1091,7 +1091,8 @@ function _drawFaviconBadge() { * Delegates drawing to _drawFaviconBadge which manages the cached Image object. */ function updateFaviconBadge() { - var hasActivity = _currentSessions && _currentSessions.some(function (s) { + var visible = getVisibleSessions(_currentSessions); + var hasActivity = visible.length > 0 && visible.some(function (s) { return s.bell && s.bell.unseen_count > 0; }); @@ -1122,7 +1123,8 @@ function updatePageTitle() { var hostname = (_serverSettings && _serverSettings.device_name) || (typeof location !== 'undefined' ? location.hostname : null) || 'muxplex'; - var count = (_currentSessions || []).filter(function(s) { + var visible = getVisibleSessions(_currentSessions); + var count = visible.filter(function(s) { return s.bell && s.bell.unseen_count > 0; }).length; var prefix = count > 0 ? '(' + count + ') ' : ''; @@ -2547,6 +2549,7 @@ if (typeof module !== 'undefined' && module.exports) { renderSheetList, updateSessionPill, updatePageTitle, + updateFaviconBadge, // ANSI color rendering ansiToHtml, ansiParamsToStyle, diff --git a/muxplex/frontend/tests/test_app.mjs b/muxplex/frontend/tests/test_app.mjs index 536d8bb..d108d03 100644 --- a/muxplex/frontend/tests/test_app.mjs +++ b/muxplex/frontend/tests/test_app.mjs @@ -4641,3 +4641,63 @@ test('getVisibleSessions hides local session (remoteId null) but keeps remote se assert.strictEqual(visible[0].remoteId, 0, 'the visible session must be the remote one with remoteId=0'); app._setServerSettings(null); }); + +// --- task-2: updatePageTitle and updateFaviconBadge filter hidden sessions --- + +test('updatePageTitle excludes hidden sessions from bell count', () => { + // Set up server settings with hidden_sessions + app._setServerSettings({ hidden_sessions: ['hidden-build'] }); + + // Create two sessions: visible-dev (unseen_count 2) and hidden-build (unseen_count 5) + app._setCurrentSessions([ + { name: 'visible-dev', bell: { unseen_count: 2 } }, + { name: 'hidden-build', bell: { unseen_count: 5 } }, + ]); + + // Call updatePageTitle + app.updatePageTitle(); + + // Assert title starts with '(1)' — only visible-dev counts, not hidden-build + assert.ok( + document.title.startsWith('(1)'), + `title must start with '(1)' when only 1 visible session has bells, got '${document.title}'` + ); + + // Clean up + app._setServerSettings(null); + app._setCurrentSessions([]); +}); + +test('updateFaviconBadge does not show activity for only-hidden sessions with bells', () => { + // Set up server settings with hidden_sessions + app._setServerSettings({ hidden_sessions: ['hidden-build'] }); + + // Create only a hidden session with bell activity + app._setCurrentSessions([ + { name: 'hidden-build', bell: { unseen_count: 5 } }, + ]); + + // Mock document.querySelector to return a fake link element + const origQS = globalThis.document.querySelector; + const faviconHref = 'http://localhost/favicon.ico'; + const fakeLink = { href: faviconHref }; + globalThis.document.querySelector = (sel) => { + if (sel && sel.includes('icon')) return fakeLink; + return null; + }; + + // Call updateFaviconBadge — should NOT draw badge since no visible sessions have bells + app.updateFaviconBadge(); + + // favicon href must not change — no badge should be applied + assert.strictEqual( + fakeLink.href, + faviconHref, + 'favicon href must not change when only hidden sessions have bell activity' + ); + + // Restore mocks and state + globalThis.document.querySelector = origQS; + app._setServerSettings(null); + app._setCurrentSessions([]); +}); diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index 866121e..f2d4ada 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -2733,3 +2733,36 @@ def test_get_visible_sessions_uses_null_check_not_falsy() -> None: "getVisibleSessions must use 's.remoteId == null' (or '=== null') to correctly treat " "remoteId=0 as a real remote while matching null/undefined as local sessions." ) + + +# ─── task-2: updatePageTitle and updateFaviconBadge filter hidden sessions ───── + + +def test_update_page_title_filters_through_visible_sessions() -> None: + """updatePageTitle() must count bell activity from getVisibleSessions(), not all sessions.""" + match = re.search( + r"function updatePageTitle\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// ─|\n/\*\*)", + _JS, + re.DOTALL, + ) + assert match, "updatePageTitle function not found in app.js" + body = match.group(1) + assert "getVisibleSessions" in body, ( + "updatePageTitle must filter sessions through getVisibleSessions() " + "to exclude hidden sessions from the bell count" + ) + + +def test_update_favicon_badge_filters_through_visible_sessions() -> None: + """updateFaviconBadge() must check bell activity from getVisibleSessions(), not all sessions.""" + match = re.search( + r"function updateFaviconBadge\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\n// ─|\n/\*\*)", + _JS, + re.DOTALL, + ) + assert match, "updateFaviconBadge function not found in app.js" + body = match.group(1) + assert "getVisibleSessions" in body, ( + "updateFaviconBadge must filter sessions through getVisibleSessions() " + "to exclude hidden sessions from the bell activity check" + ) diff --git a/uv.lock b/uv.lock index b69aa8a..54d2f6e 100644 --- a/uv.lock +++ b/uv.lock @@ -332,7 +332,7 @@ wheels = [ [[package]] name = "muxplex" -version = "0.2.0" +version = "0.2.1" source = { editable = "." } dependencies = [ { name = "aiofiles" },