From 80c95236535038c4531916a939cab192414ade71 Mon Sep 17 00:00:00 2001 From: Rae Lovejoy Date: Sun, 27 Sep 2026 11:18:51 -0700 Subject: [PATCH 1/2] hub: reconcile cached profiles with live target state --- docs/HUB.md | 2 ++ docs/STATUS.md | 2 +- src/relmote/cli.py | 16 +++++++-- src/relmote/hub_inventory.py | 68 ++++++++++++++++++++++++++++++++++++ tests/test_hub_inventory.py | 59 +++++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 3 deletions(-) diff --git a/docs/HUB.md b/docs/HUB.md index bb2562e..396205c 100644 --- a/docs/HUB.md +++ b/docs/HUB.md @@ -135,6 +135,8 @@ It reports: The live probe distinguishes a reachable-but-revoked Target from an unreachable Target. The snapshot does not expose paired-target bearer credentials or stored endpoint URLs. +Live inventory also reconciles cached pairing metadata with current Target truth. Cached profile state is labeled as such; current health is classified as `active`, `revoked`, `stale_credential`, `unreachable`, `denied`, `inactive`, or `unknown`, with a non-destructive recommended action when attention is needed. + This is inventory only. It cannot create grants, execute Target operations, update nodes, or act as a relay. ## Initial Hub MVP diff --git a/docs/STATUS.md b/docs/STATUS.md index f35df3a..e7ca48d 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -23,7 +23,7 @@ This document is the canonical high-level implementation-status snapshot. Detail ## Implemented, actively experimental -- First Hub MVP slice: credential-blind local Agent Host/paired-target inventory with optional live reachability/authority probes. +- First Hub MVP slice: credential-blind local Agent Host/paired-target inventory with live reachability/authority probes, cached-vs-current state reconciliation, and non-destructive attention guidance. - Cross-platform/private-transport portability beyond the exercised Linux direct-Tailscale path. - Wayland ScreenCast portal integration. diff --git a/src/relmote/cli.py b/src/relmote/cli.py index c641cc1..ee76efa 100644 --- a/src/relmote/cli.py +++ b/src/relmote/cli.py @@ -732,6 +732,14 @@ def run_hub_inventory(args): print() print("PAIRED TARGETS") targets = snapshot["paired_targets"] + if args.live: + summary = snapshot.get("summary") or {} + print( + "Summary: " + f"{summary.get('total', len(targets))} total · " + f"{summary.get('active', 0)} active · " + f"{summary.get('attention', 0)} need attention" + ) if not targets: print("No paired Relmote targets.") return 0 @@ -739,10 +747,10 @@ def run_hub_inventory(args): for target in targets: print(f"- {target['name']}") print(f" workspace: {target.get('workspace') or '?'}") - print(f" stored state: {target.get('state') or '?'}") + print(f" paired profile state (cached): {target.get('state') or '?'}") capabilities = target.get("capabilities") or [] print( - " stored capabilities: " + " paired profile capabilities (cached): " + (", ".join(capabilities) if capabilities else "none") ) if args.live: @@ -754,11 +762,15 @@ def run_hub_inventory(args): reachability = "unreachable" else: reachability = "unknown" + current = target.get("current") or {} print(f" live: {reachability} · {live.get('authority') or 'unknown'}") + print(f" current: {current.get('health') or 'unknown'}") if live.get("state"): print(f" live state: {live['state']}") if live.get("detail"): print(f" detail: {live['detail']}") + if current.get("recommended_action"): + print(f" action: {current['recommended_action']}") return 0 hub_inventory.set_defaults(func=run_hub_inventory) diff --git a/src/relmote/hub_inventory.py b/src/relmote/hub_inventory.py index 526dca9..64507fc 100644 --- a/src/relmote/hub_inventory.py +++ b/src/relmote/hub_inventory.py @@ -50,6 +50,53 @@ def _live_error(exc: Exception) -> dict[str, Any]: "detail": str(exc), } + @staticmethod + def _reconcile_live(live: dict[str, Any]) -> dict[str, Any]: + reachable = live.get("reachable") + authority = live.get("authority") + state = live.get("state") + detail = str(live.get("detail") or "") + lowered = detail.lower() + + if reachable is False: + return { + "health": "unreachable", + "needs_attention": True, + "recommended_action": "check private transport and target availability", + } + if authority == "active" and state == "active": + return { + "health": "active", + "needs_attention": False, + "recommended_action": None, + } + if authority == "denied": + if "invalid agent session token" in lowered: + health = "stale_credential" + action = "create a new Target grant and re-pair this profile" + elif "session is not active" in lowered: + health = "revoked" + action = "create a new Target grant and re-pair if access is still needed" + else: + health = "denied" + action = "review the Target grant before re-pairing" + return { + "health": health, + "needs_attention": True, + "recommended_action": action, + } + if state and state != "active": + return { + "health": "inactive", + "needs_attention": True, + "recommended_action": "review or replace the inactive Target grant", + } + return { + "health": "unknown", + "needs_attention": True, + "recommended_action": "review live Target status", + } + def snapshot(self, *, live: bool = False) -> dict[str, Any]: host = self._host_factory() build = self._build_factory() @@ -78,8 +125,28 @@ def snapshot(self, *, live: bool = False) -> dict[str, Any]: "workspace": status.get("workspace"), "capabilities": list(status.get("capabilities") or []), } + item["current"] = self._reconcile_live(item["live"]) targets.append(item) + summary = { + "total": len(targets), + "active": 0, + "attention": 0, + "unreachable": 0, + "revoked": 0, + "stale_credential": 0, + } + if live: + for item in targets: + current = item.get("current") or {} + health = current.get("health") + if health == "active": + summary["active"] += 1 + if current.get("needs_attention"): + summary["attention"] += 1 + if health in summary: + summary[health] += 1 + return { "hub": { "role": "hub", @@ -98,4 +165,5 @@ def snapshot(self, *, live: bool = False) -> dict[str, Any]: }, }, "paired_targets": targets, + "summary": summary, } diff --git a/tests/test_hub_inventory.py b/tests/test_hub_inventory.py index 82406d1..897f9b2 100644 --- a/tests/test_hub_inventory.py +++ b/tests/test_hub_inventory.py @@ -109,3 +109,62 @@ def test_hub_live_inventory_distinguishes_revoked_from_unreachable(): assert unavailable["reachable"] is False assert unavailable["authority"] == "unknown" assert "connection refused" in unavailable["detail"] + + +def test_hub_reconciles_revoked_and_stale_credentials_into_attention_states(): + revoked = HubInventory( + paired_targets=FakePairedTargets( + status_error=PermissionError( + "Relmote Agent API: agent session is not active" + ) + ), + host_factory=fake_host, + build_factory=fake_build, + ).snapshot(live=True) + + stale = HubInventory( + paired_targets=FakePairedTargets( + status_error=PermissionError( + "Relmote Agent API: invalid agent session token" + ) + ), + host_factory=fake_host, + build_factory=fake_build, + ).snapshot(live=True) + + revoked_target = revoked["paired_targets"][0] + assert revoked_target["current"]["health"] == "revoked" + assert revoked_target["current"]["needs_attention"] is True + assert "re-pair" in revoked_target["current"]["recommended_action"] + assert revoked["summary"]["revoked"] == 1 + assert revoked["summary"]["attention"] == 1 + + stale_target = stale["paired_targets"][0] + assert stale_target["current"]["health"] == "stale_credential" + assert stale_target["current"]["needs_attention"] is True + assert "re-pair" in stale_target["current"]["recommended_action"] + assert stale["summary"]["stale_credential"] == 1 + + +def test_hub_summary_counts_active_and_unreachable_targets(): + active = HubInventory( + paired_targets=FakePairedTargets(), + host_factory=fake_host, + build_factory=fake_build, + ).snapshot(live=True) + + unreachable = HubInventory( + paired_targets=FakePairedTargets( + status_error=ConnectionError("connection refused") + ), + host_factory=fake_host, + build_factory=fake_build, + ).snapshot(live=True) + + assert active["summary"]["active"] == 1 + assert active["summary"]["attention"] == 0 + assert active["paired_targets"][0]["current"]["health"] == "active" + + assert unreachable["summary"]["unreachable"] == 1 + assert unreachable["summary"]["attention"] == 1 + assert unreachable["paired_targets"][0]["current"]["health"] == "unreachable" From a63e65c8d2b3a469c2e0c0dd1cb71d5a9c334f12 Mon Sep 17 00:00:00 2001 From: Rae Lovejoy Date: Sun, 27 Sep 2026 11:20:02 -0700 Subject: [PATCH 2/2] hub: fix live summary counting --- src/relmote/hub_inventory.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/relmote/hub_inventory.py b/src/relmote/hub_inventory.py index 64507fc..1921807 100644 --- a/src/relmote/hub_inventory.py +++ b/src/relmote/hub_inventory.py @@ -140,8 +140,6 @@ def snapshot(self, *, live: bool = False) -> dict[str, Any]: for item in targets: current = item.get("current") or {} health = current.get("health") - if health == "active": - summary["active"] += 1 if current.get("needs_attention"): summary["attention"] += 1 if health in summary: