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 +29 -0
- elo/__main__.py +153 -0
- elo/node.py +498 -0
- elo/security.py +201 -0
- elo/transport/__init__.py +34 -0
- elo/transport/protocol.py +166 -0
- elo/transport/routing.py +135 -0
- elo/transport/tcp.py +327 -0
- elo/transport/tracker.py +97 -0
- elo/types.py +126 -0
- elo_node-0.4.0.dist-info/METADATA +107 -0
- elo_node-0.4.0.dist-info/RECORD +15 -0
- elo_node-0.4.0.dist-info/WHEEL +5 -0
- elo_node-0.4.0.dist-info/entry_points.txt +2 -0
- elo_node-0.4.0.dist-info/top_level.txt +1 -0
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()
|