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 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()))