Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/HUB.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 14 additions & 2 deletions src/relmote/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -732,17 +732,25 @@ 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

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:
Expand All @@ -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)
Expand Down
66 changes: 66 additions & 0 deletions src/relmote/hub_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -78,8 +125,26 @@ 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 current.get("needs_attention"):
summary["attention"] += 1
if health in summary:
summary[health] += 1

return {
"hub": {
"role": "hub",
Expand All @@ -98,4 +163,5 @@ def snapshot(self, *, live: bool = False) -> dict[str, Any]:
},
},
"paired_targets": targets,
"summary": summary,
}
59 changes: 59 additions & 0 deletions tests/test_hub_inventory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading