54 KiB
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:
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:
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:
if (hidden.length > 0 && !s.remoteId && hidden.includes(s.name)) {
to:
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
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:
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:
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:
function updateFaviconBadge() {
var hasActivity = _currentSessions && _currentSessions.some(function (s) {
return s.bell && s.bell.unseen_count > 0;
});
To:
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:
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:
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
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_clientreference) - Modify:
muxplex/main.py:161-189(set_federation_clientin lifespan) - Test:
muxplex/tests/test_settings.pyor new test inmuxplex/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:
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:
_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:
global _federation_client
_federation_client = app.state.federation_client
And in the shutdown section, after await client.aclose() (around line 196), add:
_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:
# 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
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:
# ============================================================
# 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:
# 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):
"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
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(updatepatch_settings, addapply_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:
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:
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:
# 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()):
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
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(addSYNCABLE_KEYSto 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:
# ============================================================
# 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:
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:
@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
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:
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:
@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
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:
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:
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:
# 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
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:
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