elo-node 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
elo/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """
2
+ Elo — malha P2P de mensagens para agentes de IA.
3
+
4
+ Um processo. Uma porta TCP. Uma chave ed25519.
5
+ Zero infraestrutura externa.
6
+
7
+ Uso:
8
+ from elo import Node
9
+
10
+ node = Node("meu-agente", port=7878)
11
+ await node.connect()
12
+ await node.register(agents=["analyst"], tools=["web-search"])
13
+
14
+ @node.on_task
15
+ async def handle(task):
16
+ return {"result": "ok"}
17
+
18
+ await node.run()
19
+ """
20
+
21
+ from elo.node import Node
22
+ from elo.security import EphemeralIdentity, generate_and_save_identity, load_identity
23
+ from elo.types import Task, Result, Event, NodeInfo, Capabilities
24
+
25
+ __all__ = [
26
+ "Node", "Task", "Result", "Event", "NodeInfo", "Capabilities",
27
+ "EphemeralIdentity", "generate_and_save_identity", "load_identity",
28
+ ]
29
+ __version__ = "0.4.0"
elo/__main__.py ADDED
@@ -0,0 +1,153 @@
1
+ """Elo CLI — gerenciamento de identidade e status do nó.
2
+
3
+ Uso:
4
+ python -m elo status # Status completo (id, chaves)
5
+ python -m elo id # Apenas o node_id
6
+ python -m elo pubkey # Chave pública completa
7
+ python -m elo init # Gera e salva identidade persistente
8
+ python -m elo serve # Inicia um nó interativo
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import asyncio
15
+ import hashlib
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from elo.security import (
21
+ EphemeralIdentity,
22
+ generate_and_save_identity,
23
+ load_identity,
24
+ pubkey_to_id,
25
+ DEFAULT_KEY_DIR,
26
+ )
27
+
28
+
29
+ def _short_id(node_id: str, n: int = 12) -> str:
30
+ h = hashlib.sha256(node_id.encode()).hexdigest()[:16]
31
+ return f"{node_id[:n]}... (sha256:{h})"
32
+
33
+
34
+ def cmd_status() -> None:
35
+ """Exibe o status completo do nó (identidade, chaves)."""
36
+ identity = EphemeralIdentity()
37
+ node_id = identity.node_id
38
+ pubkey_bytes = identity.public_key.public_bytes_raw()
39
+
40
+ print("=" * 52)
41
+ print(" ELO NODE STATUS")
42
+ print("=" * 52)
43
+ print(f" Node ID: {node_id}")
44
+ print(f" Hash: {_short_id(node_id)}")
45
+ print("-" * 52)
46
+ print(f" Algorithm: ed25519")
47
+ print("-" * 52)
48
+ print(f" Public key (hex):")
49
+ print(f" {pubkey_bytes.hex()}")
50
+ print(f" Public key (b64):")
51
+ print(f" {node_id}")
52
+ print("-" * 52)
53
+
54
+ seed_path = DEFAULT_KEY_DIR / "identity.seed"
55
+ if seed_path.exists():
56
+ try:
57
+ priv, x25519 = load_identity()
58
+ pub = priv.public_key()
59
+ saved_id = pubkey_to_id(pub)
60
+ print(f" Persisted: {DEFAULT_KEY_DIR}")
61
+ print(f" Saved ID: {saved_id}")
62
+ except Exception:
63
+ print(f" Persisted: ERROR loading from {DEFAULT_KEY_DIR}")
64
+ else:
65
+ print(f" Persisted: no (ephemeral)")
66
+ print(f" Use 'python -m elo init' to save")
67
+ print(f" Default dir: {DEFAULT_KEY_DIR}")
68
+
69
+ print("=" * 52)
70
+
71
+
72
+ def cmd_id() -> None:
73
+ identity = EphemeralIdentity()
74
+ print(identity.node_id)
75
+
76
+
77
+ def cmd_pubkey() -> None:
78
+ identity = EphemeralIdentity()
79
+ node_id = identity.node_id
80
+ pubkey = identity.public_key.public_bytes_raw()
81
+ print(f"node_id (b64): {node_id}")
82
+ print(f"public (hex): {pubkey.hex()}")
83
+ print(f"algorithm: ed25519")
84
+
85
+
86
+ def cmd_init() -> None:
87
+ key_dir = DEFAULT_KEY_DIR
88
+ pub, priv = generate_and_save_identity(key_dir)
89
+ node_id = pubkey_to_id(pub)
90
+ print(f"Identity generated and saved to: {key_dir}")
91
+ print(f" identity.seed -- ed25519 private key")
92
+ print()
93
+ print(f"Node ID: {node_id}")
94
+ print(f"Hash: {_short_id(node_id)}")
95
+ print()
96
+ print("!! Keep identity.seed safe -- it is your node's identity.")
97
+ print(" Without it, peers will not recognize this node after restart.")
98
+
99
+
100
+ def cmd_serve() -> None:
101
+ from elo import Node
102
+
103
+ async def _serve():
104
+ node = Node("elo-cli", port=7878)
105
+ await node.connect()
106
+ await node.register(agents=["echo-cli"])
107
+
108
+ @node.on_task
109
+ async def handle(task):
110
+ print(f"[task] {task.capability}: {task.payload}")
111
+ return {"echo": task.payload, "from": node.node_id}
112
+
113
+ print(f"[elo] serving on port {node.port} | id={_short_id(node.node_id)}")
114
+ print(f"[elo] Press Ctrl+C to stop")
115
+ await node.run()
116
+
117
+ try:
118
+ asyncio.run(_serve())
119
+ except KeyboardInterrupt:
120
+ print("\n[elo] stopped")
121
+
122
+
123
+ def main() -> None:
124
+ parser = argparse.ArgumentParser(
125
+ prog="elo",
126
+ description="Elo CLI — malha P2P para agentes de IA",
127
+ )
128
+ sub = parser.add_subparsers(dest="command", help="Comandos")
129
+
130
+ sub.add_parser("status", help="Status completo do nó (identidade + chaves)")
131
+ sub.add_parser("id", help="Apenas o node_id")
132
+ sub.add_parser("pubkey", help="Chave pública em formatos úteis")
133
+ sub.add_parser("init", help="Gerar e salvar identidade persistente")
134
+ sub.add_parser("serve", help="Iniciar um nó interativo (porta 7878)")
135
+
136
+ args = parser.parse_args()
137
+
138
+ commands = {
139
+ "status": cmd_status,
140
+ "id": cmd_id,
141
+ "pubkey": cmd_pubkey,
142
+ "init": cmd_init,
143
+ "serve": cmd_serve,
144
+ }
145
+
146
+ if args.command in commands:
147
+ commands[args.command]()
148
+ else:
149
+ parser.print_help()
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()
elo/node.py ADDED
@@ -0,0 +1,498 @@
1
+ """Elo Node — nó P2P da malha Elo. Um processo, uma porta, uma chave.
2
+
3
+ Uso:
4
+ node = Node("meu-agente", port=7878)
5
+ await node.connect()
6
+ await node.register(agents=["analyst"], tools=["web-search"])
7
+
8
+ @node.on_task
9
+ async def handle(task):
10
+ return {"result": "ok"}
11
+
12
+ await node.run()
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import hashlib
19
+ import logging
20
+ import time
21
+ import uuid
22
+ from collections.abc import Callable, Awaitable
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from elo.transport import (
27
+ TCPManager,
28
+ InterestTable,
29
+ LocalTracker,
30
+ hello_msg,
31
+ hello_ack_msg,
32
+ query_msg,
33
+ query_resp_msg,
34
+ interest_update_msg,
35
+ task_msg as p2p_task_msg,
36
+ result_msg,
37
+ MessageType,
38
+ )
39
+ from elo.security import EphemeralIdentity, load_identity, pubkey_to_id
40
+ from elo.types import Capabilities, Result, Task
41
+
42
+ logger = logging.getLogger("elo")
43
+
44
+ DEFAULT_DATA_DIR = Path.home() / ".elo"
45
+
46
+
47
+ def _load_or_generate_identity() -> EphemeralIdentity:
48
+ """Carrega identidade persistente se existir, senão gera efêmera."""
49
+ seed_path = DEFAULT_DATA_DIR / "identity.seed"
50
+ if seed_path.exists():
51
+ try:
52
+ priv, _ = load_identity(DEFAULT_DATA_DIR)
53
+ pub = priv.public_key()
54
+ node_id = pubkey_to_id(pub)
55
+ logger.info("[elo] loaded persistent identity from %s", DEFAULT_DATA_DIR)
56
+ identity = EphemeralIdentity.__new__(EphemeralIdentity)
57
+ identity._private_key = priv
58
+ identity._public_key = pub
59
+ return identity
60
+ except Exception as e:
61
+ logger.warning("[elo] failed to load identity: %s — generating ephemeral", e)
62
+ logger.info("[elo] no persistent identity — generated ephemeral (use 'python -m elo init' to persist)")
63
+ return EphemeralIdentity()
64
+
65
+
66
+ class Node:
67
+ """Nó P2P da malha Elo.
68
+
69
+ Um processo. Uma porta TCP. Uma chave ed25519.
70
+ Zero infraestrutura externa.
71
+ """
72
+
73
+ def __init__(
74
+ self,
75
+ name: str,
76
+ *,
77
+ port: int = 7878,
78
+ peers: list[str] | None = None,
79
+ tracker: str = "public",
80
+ allowlist: list[str] | None = None,
81
+ version: str = "0.4.0",
82
+ identity: EphemeralIdentity | None = None,
83
+ verify_peers: bool = True,
84
+ heartbeat_interval_s: int = 30,
85
+ labels: dict[str, str] | None = None,
86
+ ):
87
+ self._name = name
88
+ self._version = version
89
+ self._port = port
90
+ self._initial_peers = peers or []
91
+ self._heartbeat_interval = heartbeat_interval_s
92
+ self._labels = labels or {}
93
+ self._verify_peers = verify_peers
94
+
95
+ # Identidade
96
+ self._identity = identity or _load_or_generate_identity()
97
+ self._node_id = self._identity.node_id
98
+
99
+ # Transporte
100
+ self._tcp = TCPManager(self._node_id, port=port)
101
+
102
+ # Roteamento
103
+ self._routing = InterestTable()
104
+ self._tracker = LocalTracker(visibility=tracker)
105
+ if allowlist:
106
+ for nid in allowlist:
107
+ self._tracker.allow_peer(nid)
108
+
109
+ # Estado
110
+ self._task_handler: Callable[[Task], Awaitable[dict[str, Any]]] | None = None
111
+ self._shutdown_event = asyncio.Event()
112
+ self._pending_results: dict[str, asyncio.Future] = {}
113
+ self._pending_queries: dict[str, tuple[asyncio.Future, float]] = {}
114
+
115
+ # Cache de pubkeys para verificação de assinatura
116
+ self._pubkey_cache: dict[str, tuple[Any, float]] = {}
117
+ self._cache_ttl_s = heartbeat_interval_s * 5
118
+
119
+ # ── propriedades ──────────────────────────────────────────
120
+
121
+ @property
122
+ def node_id(self) -> str:
123
+ return self._node_id
124
+
125
+ @property
126
+ def connected(self) -> bool:
127
+ return self._tcp._server is not None
128
+
129
+ @property
130
+ def identity(self) -> EphemeralIdentity:
131
+ return self._identity
132
+
133
+ @property
134
+ def port(self) -> int:
135
+ return self._tcp.port
136
+
137
+ @property
138
+ def peer_count(self) -> int:
139
+ return self._tcp.peer_count
140
+
141
+ @property
142
+ def tracker_visibility(self) -> str:
143
+ return self._tracker.visibility
144
+
145
+ # ── observabilidade mínima ─────────────────────────────────
146
+
147
+ def metrics(self) -> dict[str, Any]:
148
+ return {
149
+ "node_id": self._node_id[:12],
150
+ "port": self._port,
151
+ "peers_connected": self._tcp.peer_count,
152
+ "peer_addresses": self._tcp.peer_addresses,
153
+ "tracker": self._tracker.visibility,
154
+ "caps": len(self._routing.local_caps),
155
+ }
156
+
157
+ # ── ciclo de vida ─────────────────────────────────────────
158
+
159
+ async def connect(self) -> None:
160
+ if self.connected:
161
+ return
162
+
163
+ actual_port = await self._tcp.start()
164
+ self._port = actual_port
165
+ self._tcp.on_message(self._handle_message)
166
+
167
+ # Conecta a peers manuais
168
+ hello = hello_msg(
169
+ self._node_id,
170
+ self._tracker.get_public_caps(),
171
+ list(self._routing.local_interests),
172
+ self._tracker.visibility,
173
+ self._version,
174
+ )
175
+ for addr in self._initial_peers:
176
+ await self._tcp.connect_to_peer(addr, hello_payload=hello)
177
+
178
+ logger.info("[elo] connected | node=%s id=%s port=%d",
179
+ self._name, self._node_id[:12], actual_port)
180
+
181
+ async def disconnect(self) -> None:
182
+ self._shutdown_event.set()
183
+ await self._tcp.stop()
184
+ logger.info("[elo] disconnected | node=%s", self._name)
185
+
186
+ # ── registro ──────────────────────────────────────────────
187
+
188
+ async def register(
189
+ self,
190
+ *,
191
+ agents: list[str] | None = None,
192
+ models: list[str] | None = None,
193
+ tools: list[str] | None = None,
194
+ agent_details: list[dict[str, str]] | None = None,
195
+ model_details: list[dict[str, str]] | None = None,
196
+ tool_details: list[dict[str, str]] | None = None,
197
+ ) -> None:
198
+ agent_caps = agent_details or [{"name": a} for a in (agents or [])]
199
+ model_caps = model_details or [{"name": m} for m in (models or [])]
200
+ tool_caps = tool_details or [{"name": t} for t in (tools or [])]
201
+
202
+ self._tracker.register(agents=agent_caps, models=model_caps, tools=tool_caps)
203
+ self._routing.set_local_caps(self._tracker.caps)
204
+
205
+ interests = [a.get("name", "") for a in agent_caps]
206
+ interests += [t.get("name", "") for t in tool_caps]
207
+ self._tracker.set_interests(interests)
208
+ self._routing.set_local_interests(interests)
209
+
210
+ update = interest_update_msg(interests)
211
+ await self._tcp.broadcast(update)
212
+
213
+ logger.info("[elo] registered | agents=%d models=%d tools=%d",
214
+ len(agent_caps), len(model_caps), len(tool_caps))
215
+
216
+ # ── handlers ───────────────────────────────────────────────
217
+
218
+ def on_task(self, fn: Callable[[Task], Awaitable[dict[str, Any]]]):
219
+ self._task_handler = fn
220
+ return fn
221
+
222
+ # ── mensagens ──────────────────────────────────────────────
223
+
224
+ async def send_task(self, target_node: str, capability: str,
225
+ payload: dict[str, Any], *, ttl_s: int = 60) -> Result:
226
+ """Envia task e aguarda resultado. Descobre peer se target vazio."""
227
+ if not self.connected:
228
+ raise RuntimeError("Node not connected")
229
+
230
+ task_id = str(uuid.uuid4())
231
+ task_dict = p2p_task_msg(task_id, target_node, self._node_id, capability, payload)
232
+ task_dict.pop("signature", None)
233
+ task_dict["signature"] = self._identity.sign(task_dict)
234
+
235
+ # Tenta encontrar peer
236
+ peer = target_node if target_node and target_node in self._tcp.peer_addresses else None
237
+ if not peer:
238
+ peer = self._routing.find_peer_for(capability)
239
+
240
+ # Se não encontrou, faz QUERY broadcast
241
+ if not peer:
242
+ peer = await self._query_capability(capability, ttl=5, timeout=5)
243
+ if peer:
244
+ hello = hello_msg(self._node_id, self._tracker.get_public_caps(),
245
+ list(self._routing.local_interests),
246
+ self._tracker.visibility, self._version)
247
+ peer = await self._tcp.connect_to_peer(peer, hello_payload=hello)
248
+
249
+ if peer:
250
+ try:
251
+ await self._tcp.send_to(peer, task_dict)
252
+ return await self._wait_for_result(task_id, peer)
253
+ except Exception as e:
254
+ return Result.make_error(task_id, "SEND_ERROR", str(e))
255
+
256
+ return Result.make_error(task_id, "NO_PEER", f"No peer for: {capability}")
257
+
258
+ async def send_task_async(self, target_node: str, capability: str,
259
+ payload: dict[str, Any]) -> str:
260
+ """Envia task sem esperar resultado. Retorna task_id."""
261
+ if not self.connected:
262
+ raise RuntimeError("Node not connected")
263
+
264
+ task_id = str(uuid.uuid4())
265
+ task_dict = p2p_task_msg(task_id, target_node, self._node_id, capability, payload)
266
+ task_dict.pop("signature", None)
267
+ task_dict["signature"] = self._identity.sign(task_dict)
268
+
269
+ peer = target_node or self._routing.find_peer_for(capability)
270
+ if peer:
271
+ try:
272
+ await self._tcp.send_to(peer, task_dict)
273
+ except Exception:
274
+ pass
275
+ else:
276
+ await self._tcp.broadcast(task_dict)
277
+ return task_id
278
+
279
+ async def publish_event(self, event_type: str, target_node: str = "",
280
+ data: dict[str, Any] | None = None) -> None:
281
+ if not self.connected:
282
+ raise RuntimeError("Node not connected")
283
+ event = {
284
+ "type": MessageType.EVENT, "event_type": event_type,
285
+ "data": data or {}, "id": str(uuid.uuid4()),
286
+ "protocol": "elo.v1", "timestamp": int(time.time()),
287
+ }
288
+ if target_node and target_node in self._tcp.peer_addresses:
289
+ await self._tcp.send_to(target_node, event)
290
+ else:
291
+ await self._tcp.broadcast(event)
292
+
293
+ # ── descoberta ─────────────────────────────────────────────
294
+
295
+ async def discover_peers(self) -> list[dict[str, Any]]:
296
+ result = []
297
+ for addr in self._tcp.peer_addresses:
298
+ caps = self._routing.get_peer_caps(addr)
299
+ result.append({"addr": addr, "connected": True,
300
+ "caps": list(caps.get("caps", []))})
301
+ return result
302
+
303
+ # ── run loop ──────────────────────────────────────────────
304
+
305
+ async def run(self) -> None:
306
+ if not self.connected:
307
+ raise RuntimeError("Node not connected — call connect() first")
308
+
309
+ heartbeat_task = asyncio.create_task(self._tcp.start_heartbeat())
310
+ await self.publish_event("node.online")
311
+
312
+ logger.info("[elo] running | node=%s (%s) port=%d peers=%d",
313
+ self._name, self._node_id[:12], self._port, self._tcp.peer_count)
314
+
315
+ await self._shutdown_event.wait()
316
+ heartbeat_task.cancel()
317
+
318
+ # ── message dispatcher ────────────────────────────────────
319
+
320
+ async def _handle_message(self, peer_addr: str, msg: dict[str, Any]) -> None:
321
+ msg_type = msg.get("type", "")
322
+
323
+ if msg_type == MessageType.HELLO:
324
+ await self._on_hello(peer_addr, msg)
325
+ elif msg_type == MessageType.HELLO_ACK:
326
+ await self._on_hello(peer_addr, msg)
327
+ elif msg_type == MessageType.QUERY:
328
+ await self._on_query(peer_addr, msg)
329
+ elif msg_type == MessageType.QUERY_RESP:
330
+ await self._on_query_resp(peer_addr, msg)
331
+ elif msg_type == MessageType.INTEREST_UPDATE:
332
+ await self._on_interest_update(peer_addr, msg)
333
+ elif msg_type == MessageType.TASK:
334
+ await self._on_task(peer_addr, msg)
335
+ elif msg_type == MessageType.RESULT:
336
+ await self._on_result(peer_addr, msg)
337
+ elif msg_type == MessageType.BYE:
338
+ await self._on_bye(peer_addr, msg)
339
+
340
+ # ── protocol handlers ─────────────────────────────────────
341
+
342
+ async def _on_hello(self, peer_addr: str, msg: dict) -> None:
343
+ node_id = msg.get("node_id", "")
344
+ caps = msg.get("caps", {})
345
+ interests = msg.get("interests", [])
346
+ self._routing.register_peer(peer_addr, caps, interests)
347
+
348
+ ack = hello_ack_msg(self._node_id, self._tracker.get_caps_for_peer(node_id),
349
+ list(self._routing.local_interests), self._tracker.visibility)
350
+ try:
351
+ await self._tcp.send_to(peer_addr, ack)
352
+ except Exception:
353
+ pass
354
+
355
+ async def _on_query(self, peer_addr: str, msg: dict) -> None:
356
+ capability = msg.get("capability", "")
357
+ query_id = msg.get("id", "")
358
+ ttl = msg.get("ttl", 5)
359
+ nodes = []
360
+ if self._tracker.has_capability(capability):
361
+ # Usa peer_addr (addr real do TCP) em vez de localhost — necessário para WAN/Tailscale
362
+ nodes.append({"node_id": self._node_id[:12], "addr": peer_addr})
363
+ for p in self._routing.find_all_peers_for(capability):
364
+ nodes.append({"addr": p})
365
+ if nodes:
366
+ try:
367
+ await self._tcp.send_to(peer_addr, query_resp_msg(query_id, nodes))
368
+ except Exception:
369
+ pass
370
+ elif ttl > 1:
371
+ await self._tcp.broadcast(query_msg(capability, query_id, ttl=ttl - 1), exclude={peer_addr})
372
+
373
+ async def _on_query_resp(self, peer_addr: str, msg: dict) -> None:
374
+ query_id = msg.get("id", "")
375
+ if query_id in self._pending_queries:
376
+ future, _ = self._pending_queries[query_id]
377
+ nodes = msg.get("nodes", [])
378
+ if nodes and not future.done():
379
+ future.set_result(nodes[0].get("addr", ""))
380
+
381
+ async def _on_interest_update(self, peer_addr: str, msg: dict) -> None:
382
+ existing = self._routing.get_peer_caps(peer_addr)
383
+ self._routing.register_peer(peer_addr, {
384
+ "agents": [{"name": c} for c in existing.get("caps", set())],
385
+ "tools": [], "models": [],
386
+ }, msg.get("interests", []))
387
+
388
+ async def _on_task(self, peer_addr: str, msg: dict) -> None:
389
+ try:
390
+ task_id = msg.get("id", "")
391
+ caller_id = msg.get("caller", "")
392
+ capability = msg.get("capability", "")
393
+
394
+ # Verifica assinatura
395
+ if self._verify_peers and msg.get("signature") and caller_id:
396
+ verify_data = {k: v for k, v in msg.items() if k != "signature"}
397
+ caller_pub = await self._get_caller_pubkey(caller_id)
398
+ if caller_pub:
399
+ from elo.security import verify_signature
400
+ if not verify_signature(caller_pub, verify_data, msg["signature"]):
401
+ logger.warning("[elo] bad signature from %s", caller_id[:12])
402
+ err = result_msg(task_id, "error",
403
+ error={"code": "BAD_SIGNATURE", "message": "Invalid signature"})
404
+ await self._tcp.send_to(peer_addr, err)
405
+ return
406
+
407
+ payload = msg.get("payload", {})
408
+
409
+ logger.debug("[elo] task received | id=%s capability=%s from=%s",
410
+ task_id, capability, caller_id[:12] if caller_id else "?")
411
+
412
+ task = Task(id=task_id, target=msg.get("target", ""), caller=caller_id,
413
+ capability=capability, payload=payload,
414
+ ttl_s=msg.get("ttl_s", 60),
415
+ signature=msg.get("signature", ""))
416
+
417
+ if self._task_handler:
418
+ result_payload = await self._task_handler(task)
419
+ else:
420
+ result_payload = {"message": "no handler registered"}
421
+
422
+ result = result_msg(task_id, "success", payload=result_payload)
423
+ await self._tcp.send_to(peer_addr, result)
424
+
425
+ except Exception as e:
426
+ logger.exception("[elo] task error")
427
+ try:
428
+ err = result_msg(msg.get("id", "unknown"), "error",
429
+ error={"code": "INTERNAL", "message": str(e)})
430
+ await self._tcp.send_to(peer_addr, err)
431
+ except Exception:
432
+ pass
433
+
434
+ async def _on_result(self, peer_addr: str, msg: dict) -> None:
435
+ task_id = msg.get("id", "")
436
+ future = self._pending_results.get(task_id)
437
+ if future and not future.done():
438
+ status = msg.get("status", "error")
439
+ if status == "success":
440
+ future.set_result(Result.success(task_id, msg.get("payload", {})))
441
+ else:
442
+ future.set_result(Result.make_error(
443
+ task_id,
444
+ msg.get("error", {}).get("code", "UNKNOWN") if msg.get("error") else "UNKNOWN",
445
+ msg.get("error", {}).get("message", "") if msg.get("error") else "",
446
+ ))
447
+
448
+ async def _on_bye(self, peer_addr: str, msg: dict) -> None:
449
+ self._routing.remove_peer(peer_addr)
450
+
451
+ # ── helpers ───────────────────────────────────────────────
452
+
453
+ async def _wait_for_result(self, task_id: str, peer_addr: str) -> Result:
454
+ future: asyncio.Future = asyncio.get_event_loop().create_future()
455
+ self._pending_results[task_id] = future
456
+ try:
457
+ return await asyncio.wait_for(future, timeout=30)
458
+ except asyncio.TimeoutError:
459
+ return Result.make_error(task_id, "TIMEOUT", "No response")
460
+ finally:
461
+ self._pending_results.pop(task_id, None)
462
+
463
+ async def _query_capability(self, capability: str, ttl: int = 5,
464
+ timeout: float = 5.0) -> str | None:
465
+ query_id = str(uuid.uuid4())[:8]
466
+ future: asyncio.Future = asyncio.get_event_loop().create_future()
467
+ self._pending_queries[query_id] = (future, time.time())
468
+ await self._tcp.broadcast(query_msg(capability, query_id, ttl))
469
+ try:
470
+ return await asyncio.wait_for(future, timeout=timeout)
471
+ except asyncio.TimeoutError:
472
+ return None
473
+ finally:
474
+ self._pending_queries.pop(query_id, None)
475
+
476
+ async def _get_caller_pubkey(self, node_id: str) -> Any | None:
477
+ now = time.time()
478
+ if node_id in self._pubkey_cache:
479
+ pubkey, expires = self._pubkey_cache[node_id]
480
+ if now < expires:
481
+ return pubkey
482
+ del self._pubkey_cache[node_id]
483
+ try:
484
+ from elo.security import id_to_pubkey
485
+ pubkey = id_to_pubkey(node_id)
486
+ self._pubkey_cache[node_id] = (pubkey, now + self._cache_ttl_s)
487
+ return pubkey
488
+ except Exception:
489
+ return None
490
+
491
+ # ── contexto ──────────────────────────────────────────────
492
+
493
+ async def __aenter__(self):
494
+ await self.connect()
495
+ return self
496
+
497
+ async def __aexit__(self, *args):
498
+ await self.disconnect()