diff --git a/muxplex/main.py b/muxplex/main.py index d9171ae..0ee6a2f 100644 --- a/muxplex/main.py +++ b/muxplex/main.py @@ -1217,10 +1217,10 @@ async def auth_mode_endpoint(): return {"mode": _auth_mode, "user": username} -# Module-level cache: remote_id → {"sessions": [...], "fail_count": int} +# Module-level cache: remote_device_id → {"sessions": [...], "fail_count": int} # Populated by fetch_remote() on every successful poll; returned on transient failures # so a single slow/dropped request doesn't immediately evict a device from the UI. -_federation_cache: dict[int, dict] = {} +_federation_cache: dict[str, dict] = {} _FEDERATION_GRACE_FAILURES = 3 # consecutive failures before marking unreachable @@ -1235,9 +1235,10 @@ async def federation_sessions(request: Request) -> list[dict]: """ settings = load_settings() local_device_name: str = settings.get("device_name", "") + local_device_id: str = load_device_id() remote_instances: list[dict] = settings.get("remote_instances", []) - # Build local sessions with deviceName/remoteId tags + # Build local sessions with deviceId/deviceName/remoteId/sessionKey tags names = get_session_list() snapshots = get_snapshots() state = await read_state() @@ -1250,8 +1251,10 @@ async def federation_sessions(request: Request) -> list[dict]: "name": name, "snapshot": snapshots.get(name, ""), "bell": bell, + "deviceId": local_device_id, "deviceName": local_device_name, "remoteId": None, + "sessionKey": f"{local_device_id}:{name}", } ) @@ -1271,7 +1274,7 @@ async def federation_sessions(request: Request) -> list[dict]: url: str = remote.get("url", "") key: str = remote.get("key", "") remote_name: str = remote.get("name", url) - remote_id: int = i + remote_device_id: str = remote.get("device_id", str(i)) try: resp = await http_client.get( f"{url.rstrip('/')}/api/sessions", @@ -1279,61 +1282,66 @@ async def federation_sessions(request: Request) -> list[dict]: ) if resp.status_code in (401, 403): # Auth failure — clear cache so stale data is not served - _federation_cache.pop(remote_id, None) + _federation_cache.pop(remote_device_id, None) return [ { "status": "auth_failed", - "remoteId": remote_id, + "deviceId": remote_device_id, + "remoteId": remote_device_id, "deviceName": remote_name, } ] resp.raise_for_status() sessions = resp.json() - # Tag each session with deviceName, remoteId, and unique sessionKey + # Tag each session with deviceId, deviceName, remoteId, and unique sessionKey tagged = [ { **s, + "deviceId": remote_device_id, "deviceName": remote_name, - "remoteId": remote_id, - "sessionKey": f"{remote_id}:{s.get('name', '')}", + "remoteId": remote_device_id, + "sessionKey": f"{remote_device_id}:{s.get('name', '')}", } for s in sessions ] # Update cache on every successful poll (even empty) - _federation_cache[remote_id] = {"sessions": tagged, "fail_count": 0} + _federation_cache[remote_device_id] = {"sessions": tagged, "fail_count": 0} if not tagged: # Device is online but has zero tmux sessions — show a status tile # rather than making the device completely invisible. return [ { "status": "empty", - "remoteId": remote_id, + "deviceId": remote_device_id, + "remoteId": remote_device_id, "deviceName": remote_name, } ] return tagged except httpx.HTTPStatusError: - cached = _federation_cache.get(remote_id) + cached = _federation_cache.get(remote_device_id) if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES: cached["fail_count"] += 1 return cached["sessions"] return [ { "status": "unreachable", - "remoteId": remote_id, + "deviceId": remote_device_id, + "remoteId": remote_device_id, "deviceName": remote_name, } ] except Exception as exc: _log.warning("Unexpected error fetching remote %s: %s", url, exc) - cached = _federation_cache.get(remote_id) + cached = _federation_cache.get(remote_device_id) if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES: cached["fail_count"] += 1 return cached["sessions"] return [ { "status": "unreachable", - "remoteId": remote_id, + "deviceId": remote_device_id, + "remoteId": remote_device_id, "deviceName": remote_name, } ] diff --git a/muxplex/tests/test_api.py b/muxplex/tests/test_api.py index 6d1f023..3ba3ede 100644 --- a/muxplex/tests/test_api.py +++ b/muxplex/tests/test_api.py @@ -1754,11 +1754,11 @@ def test_federation_sessions_returns_local_sessions(client, monkeypatch, tmp_pat def test_federation_sessions_remote_id_is_integer_index(client, monkeypatch, tmp_path): - """GET /api/federation/sessions returns integer remoteId (index) for remote sessions. + """GET /api/federation/sessions returns device_id-based remoteId for remote sessions. - remoteId must be the enumerate index (0, 1, 2...) of the remote in - remote_instances -- NOT the URL string and NOT any 'id' field from the - remote config dict. + When a remote doesn't have a device_id field, remoteId falls back to str(index) + (e.g. '0', '1', '2'...) -- NOT the URL string and NOT any 'id' field from the + remote config dict. When device_id is set, it is used directly. """ import json @@ -1823,37 +1823,43 @@ def test_federation_sessions_remote_id_is_integer_index(client, monkeypatch, tmp (s for s in remote_entries if s.get("deviceName") == "spark-2"), None ) assert spark2_session is not None, "Expected a session entry from spark-2" - assert spark2_session["remoteId"] == 0, ( - f"remoteId for first remote (index 0) must be integer 0, " + assert spark2_session["remoteId"] == "0", ( + f"remoteId for first remote (no device_id, index 0) must be string '0', " f"got: {spark2_session['remoteId']!r}" ) - assert isinstance(spark2_session["remoteId"], int), ( - f"remoteId must be an int, got {type(spark2_session['remoteId'])}" + assert isinstance(spark2_session["remoteId"], str), ( + f"remoteId must be a str, got {type(spark2_session['remoteId'])}" ) - # The unreachable remote (spark-3, index 1) must have remoteId == 1 + # The unreachable remote (spark-3, index 1) must have remoteId == '1' spark3_entry = next( (s for s in remote_entries if s.get("deviceName") == "spark-3"), None ) assert spark3_entry is not None, "Expected a status entry from spark-3" - assert spark3_entry["remoteId"] == 1, ( - f"remoteId for second remote (index 1) must be integer 1, " + assert spark3_entry["remoteId"] == "1", ( + f"remoteId for second remote (no device_id, index 1) must be string '1', " f"got: {spark3_entry['remoteId']!r}" ) -def test_federation_sessions_local_sessions_have_no_session_key( +def test_federation_sessions_local_sessions_have_session_key( client, monkeypatch, tmp_path ): - """GET /api/federation/sessions: local sessions must NOT have a sessionKey field. + """GET /api/federation/sessions: local sessions must have sessionKey = 'deviceId:name'. - Local sessions use name as the unique key — no prefix needed since they - never collide with themselves. + Local sessions are tagged with sessionKey = f'{local_device_id}:{name}' so they + can be uniquely identified in the merged multi-device session list. """ import json + import muxplex.identity as identity_mod import muxplex.settings as settings_mod + # Redirect identity file to a known UUID + identity_path = tmp_path / "identity.json" + identity_path.write_text(json.dumps({"device_id": "test-local-id"})) + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", identity_path) + settings_path = tmp_path / "settings.json" monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) @@ -1875,8 +1881,12 @@ def test_federation_sessions_local_sessions_have_no_session_key( assert len(local_sessions) == 2, f"Expected 2 local sessions, got: {local_sessions}" for local in local_sessions: - assert "sessionKey" not in local, ( - f"Local session must NOT have sessionKey field, but got: {local}" + assert "sessionKey" in local, ( + f"Local session must have sessionKey field, but got: {local}" + ) + expected_key = f"test-local-id:{local['name']}" + assert local["sessionKey"] == expected_key, ( + f"Local sessionKey must be '{expected_key}', got: {local['sessionKey']!r}" ) @@ -2030,7 +2040,9 @@ def test_federation_sessions_includes_remote_failure_status( ) entry = failure_entries[0] assert entry["status"] == "unreachable" - assert entry.get("remoteId") == 0 # integer index, not the "id" field string + assert ( + entry.get("remoteId") == "0" + ) # device_id string (fallback to str(index) when no device_id) # --------------------------------------------------------------------------- @@ -3426,6 +3438,55 @@ def test_federation_connect_by_device_id(client, monkeypatch, tmp_path): assert data["active_session"] == "my-session" +# --------------------------------------------------------------------------- +# Task-10: Tag Sessions with device_id +# --------------------------------------------------------------------------- + + +def test_federation_sessions_tags_local_with_device_id(client, monkeypatch, tmp_path): + """GET /api/federation/sessions: local sessions have deviceId and sessionKey from identity. + + The local device_id is loaded from the identity file and used to: + - tag each local session with deviceId: local_device_id + - build sessionKey: f'{local_device_id}:{name}' + """ + import json + + import muxplex.identity as identity_mod + import muxplex.settings as settings_mod + + # Redirect identity file to a known UUID + identity_path = tmp_path / "identity.json" + identity_path.write_text(json.dumps({"device_id": "local-uuid"})) + monkeypatch.setattr(identity_mod, "IDENTITY_PATH", identity_path) + + # Set up settings with no remote instances + settings_path = tmp_path / "settings.json" + monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path) + settings_path.write_text( + json.dumps({"device_name": "my-machine", "remote_instances": []}) + ) + + # Mock local session list + monkeypatch.setattr("muxplex.main.get_session_list", lambda: ["dev"]) + monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {"dev": ""}) + + response = client.get("/api/federation/sessions") + assert response.status_code == 200 + data = response.json() + + local_sessions = [s for s in data if s.get("remoteId") is None] + assert len(local_sessions) == 1, f"Expected 1 local session, got: {local_sessions}" + + local = local_sessions[0] + assert local.get("deviceId") == "local-uuid", ( + f"Local session must have deviceId='local-uuid', got: {local.get('deviceId')!r}" + ) + assert local.get("sessionKey") == "local-uuid:dev", ( + f"Local session must have sessionKey='local-uuid:dev', got: {local.get('sessionKey')!r}" + ) + + def test_federation_connect_device_id_not_found(client, monkeypatch, tmp_path): """POST /api/federation/{device_id}/connect/{session_name} returns 404 when device_id has no match. @@ -3442,9 +3503,7 @@ def test_federation_connect_device_id_not_found(client, monkeypatch, tmp_path): # No remotes configured — any device_id lookup returns None settings_path.write_text(json.dumps({"remote_instances": []})) - response = client.post( - "/api/federation/nonexistent-device/connect/my-session" - ) + response = client.post("/api/federation/nonexistent-device/connect/my-session") assert response.status_code == 404, ( f"Expected 404 for unknown device_id, got {response.status_code}: {response.text}" )