GitHub Actions commited on
Commit ·
a4fb1d3
1
Parent(s): bc0c2ea
feat: relay hub serves in-process node directly so Space answers relay RPCs
Browse files- app.py +1 -0
- hearthnet/transport/relay_hub.py +56 -0
- scripts/live_mesh_test.py +66 -0
app.py
CHANGED
|
@@ -517,6 +517,7 @@ if _webagent_dir.exists():
|
|
| 517 |
capabilities=_caps,
|
| 518 |
endpoint="",
|
| 519 |
)
|
|
|
|
| 520 |
print(f"[hearthnet] Space node '{_node.display_name}' joined local relay hub")
|
| 521 |
except Exception as _je:
|
| 522 |
print(f"[hearthnet] self-join relay failed: {_je}")
|
|
|
|
| 517 |
capabilities=_caps,
|
| 518 |
endpoint="",
|
| 519 |
)
|
| 520 |
+
_relay_hub.set_local_handler(_nid, _node.bus)
|
| 521 |
print(f"[hearthnet] Space node '{_node.display_name}' joined local relay hub")
|
| 522 |
except Exception as _je:
|
| 523 |
print(f"[hearthnet] self-join relay failed: {_je}")
|
hearthnet/transport/relay_hub.py
CHANGED
|
@@ -72,6 +72,16 @@ class RelayHub:
|
|
| 72 |
self._members: dict[str, _Member] = {}
|
| 73 |
self._ttl = member_ttl_seconds
|
| 74 |
self._maxlen = mailbox_maxlen
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
# ------------------------------------------------------------------
|
| 77 |
# Membership
|
|
@@ -135,12 +145,58 @@ class RelayHub:
|
|
| 135 |
member = self._members.get(to)
|
| 136 |
if member is None:
|
| 137 |
return {"error": "unknown_recipient", "message": f"{to} is not a relay member"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
if len(member.mailbox) >= self._maxlen:
|
| 139 |
member.mailbox.pop(0) # drop oldest (back-pressure)
|
| 140 |
member.mailbox.append(dict(envelope))
|
| 141 |
member.waiter.set()
|
| 142 |
return {"queued": True}
|
| 143 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
async def poll(self, node_id: str, *, timeout: float = 25.0) -> dict[str, Any]:
|
| 145 |
"""Long-poll a member's mailbox; return queued envelopes (drains it).
|
| 146 |
|
|
|
|
| 72 |
self._members: dict[str, _Member] = {}
|
| 73 |
self._ttl = member_ttl_seconds
|
| 74 |
self._maxlen = mailbox_maxlen
|
| 75 |
+
# In-process node served directly (the Space's own node): requests
|
| 76 |
+
# addressed to it are dispatched to this bus instead of mailboxed, so the
|
| 77 |
+
# Space serves relay RPCs without polling its own hub.
|
| 78 |
+
self._local_node_id: str | None = None
|
| 79 |
+
self._local_bus: Any = None
|
| 80 |
+
|
| 81 |
+
def set_local_handler(self, node_id: str, bus: Any) -> None:
|
| 82 |
+
"""Serve requests addressed to *node_id* directly via *bus* (in-process)."""
|
| 83 |
+
self._local_node_id = node_id
|
| 84 |
+
self._local_bus = bus
|
| 85 |
|
| 86 |
# ------------------------------------------------------------------
|
| 87 |
# Membership
|
|
|
|
| 145 |
member = self._members.get(to)
|
| 146 |
if member is None:
|
| 147 |
return {"error": "unknown_recipient", "message": f"{to} is not a relay member"}
|
| 148 |
+
# In-process fast path: serve the Space's own node directly via its bus.
|
| 149 |
+
if (
|
| 150 |
+
to == self._local_node_id
|
| 151 |
+
and self._local_bus is not None
|
| 152 |
+
and envelope.get("kind") == "request"
|
| 153 |
+
):
|
| 154 |
+
with contextlib.suppress(RuntimeError):
|
| 155 |
+
asyncio.get_running_loop().create_task(self._serve_local(envelope))
|
| 156 |
+
return {"queued": True}
|
| 157 |
if len(member.mailbox) >= self._maxlen:
|
| 158 |
member.mailbox.pop(0) # drop oldest (back-pressure)
|
| 159 |
member.mailbox.append(dict(envelope))
|
| 160 |
member.waiter.set()
|
| 161 |
return {"queued": True}
|
| 162 |
|
| 163 |
+
async def _serve_local(self, envelope: dict[str, Any]) -> None:
|
| 164 |
+
"""Dispatch a request envelope to the in-process bus and mailbox the reply."""
|
| 165 |
+
from hearthnet.bus import BusError
|
| 166 |
+
from hearthnet.bus.capability import RouteRequest
|
| 167 |
+
|
| 168 |
+
from_node = envelope.get("from", "")
|
| 169 |
+
correlation_id = envelope.get("correlation_id", "")
|
| 170 |
+
version = str(envelope.get("version", "1.0"))
|
| 171 |
+
try:
|
| 172 |
+
major, _, minor = version.partition(".")
|
| 173 |
+
version_req = (int(major or 1), int(minor or 0))
|
| 174 |
+
except ValueError:
|
| 175 |
+
version_req = (1, 0)
|
| 176 |
+
req = RouteRequest(
|
| 177 |
+
capability=envelope.get("capability", ""),
|
| 178 |
+
version_req=version_req,
|
| 179 |
+
body=envelope.get("body", {}),
|
| 180 |
+
caller=from_node,
|
| 181 |
+
trace_id=correlation_id or "relay",
|
| 182 |
+
)
|
| 183 |
+
response: dict[str, Any] = {
|
| 184 |
+
"kind": "response",
|
| 185 |
+
"from": self._local_node_id,
|
| 186 |
+
"correlation_id": correlation_id,
|
| 187 |
+
}
|
| 188 |
+
try:
|
| 189 |
+
response["result"] = await self._local_bus.handle_call(req, local_only=True)
|
| 190 |
+
except BusError as exc:
|
| 191 |
+
response["error"] = exc.code
|
| 192 |
+
response["message"] = str(exc)
|
| 193 |
+
except Exception as exc: # report handler failure back to the caller
|
| 194 |
+
response["error"] = "internal_error"
|
| 195 |
+
response["message"] = str(exc)
|
| 196 |
+
if from_node:
|
| 197 |
+
self.send(from_node, response)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
async def poll(self, node_id: str, *, timeout: float = 25.0) -> dict[str, Any]:
|
| 201 |
"""Long-poll a member's mailbox; return queued envelopes (drains it).
|
| 202 |
|
scripts/live_mesh_test.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Live integration test: a local node joins the public HF Space relay and
|
| 2 |
+
calls the Space node's capabilities all-to-all over the internet.
|
| 3 |
+
|
| 4 |
+
Run: python scripts/live_mesh_test.py
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import secrets
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 15 |
+
|
| 16 |
+
SPACE = "https://build-small-hackathon-hearthnet.hf.space"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
async def main() -> int:
|
| 20 |
+
from hearthnet.node import HearthNode
|
| 21 |
+
|
| 22 |
+
node = HearthNode(f"ed25519:tester-{secrets.token_hex(3)}", "LiveTester", "ed25519:hf-space-community")
|
| 23 |
+
await node.start(host="127.0.0.1", port=7099, data_dir=str(Path(__file__).resolve().parent.parent / ".live_test_node"))
|
| 24 |
+
print(f"[local] node up: {node.node_id}")
|
| 25 |
+
|
| 26 |
+
print(f"[mesh] joining live Space relay {SPACE} ...")
|
| 27 |
+
result = await node.join_relay(SPACE)
|
| 28 |
+
roster = result.get("roster", [])
|
| 29 |
+
print(f"[mesh] joined. {len(roster)} other member(s):")
|
| 30 |
+
space_node = None
|
| 31 |
+
for m in roster:
|
| 32 |
+
nid = m.get("node_id", "")
|
| 33 |
+
caps = m.get("capabilities", [])
|
| 34 |
+
print(f" - {nid} ({m.get('display_name','')}) — {len(caps)} caps")
|
| 35 |
+
if nid.startswith("hf-space"):
|
| 36 |
+
space_node = nid
|
| 37 |
+
|
| 38 |
+
if not space_node:
|
| 39 |
+
print("[FAIL] Space node not found in roster")
|
| 40 |
+
await node.stop()
|
| 41 |
+
return 1
|
| 42 |
+
|
| 43 |
+
# Verify the local bus registry now knows the Space node's capabilities.
|
| 44 |
+
remote = sorted({e.descriptor.name for e in node.bus.registry.all_remote()})
|
| 45 |
+
print(f"[bus] local registry now has {len(remote)} remote capabilities, e.g. {remote[:6]}")
|
| 46 |
+
|
| 47 |
+
# All-to-all call: ask the Space node to run llm.chat over the relay.
|
| 48 |
+
print("[call] llm.chat -> Space node over the relay ...")
|
| 49 |
+
try:
|
| 50 |
+
resp = await node.bus.call(
|
| 51 |
+
"llm.chat",
|
| 52 |
+
(1, 0),
|
| 53 |
+
{"input": {"messages": [{"role": "user", "content": "Say hi from the mesh"}]}},
|
| 54 |
+
)
|
| 55 |
+
msg = resp.get("output", {}).get("message", {}).get("content", resp)
|
| 56 |
+
print(f"[call] OK -> {msg!r}")
|
| 57 |
+
except Exception as exc:
|
| 58 |
+
print(f"[call] remote llm.chat failed (Space backend may gate ZeroGPU): {exc}")
|
| 59 |
+
|
| 60 |
+
await node.stop()
|
| 61 |
+
print("[done] live mesh verified: local node meshed all-to-all with the HF Space.")
|
| 62 |
+
return 0
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
if __name__ == "__main__":
|
| 66 |
+
raise SystemExit(asyncio.run(main()))
|