feat: Phase 4+5 — federation, encryption, graph visualization
Federation module with handshake protocol, trust tiers (trusted/peer/ monitored), X25519 key exchange + ChaCha20-Poly1305 encryption, and Redis store-and-forward relay (7-day TTL). Federation API: handshake, approve/reject peers, trust tier management, pending event counts. D3.js force-directed knowledge graph at /graph with entity-type shapes and colors, drag/zoom, click-to-inspect, and type filtering. Root endpoint at / with API info. Two new MCP tools for federation. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
de6e1a85b7
commit
9b22e72b5e
|
|
@ -7,3 +7,4 @@ pydantic-settings>=2.0.0
|
||||||
httpx>=0.27.0
|
httpx>=0.27.0
|
||||||
pyyaml>=6.0
|
pyyaml>=6.0
|
||||||
mcp>=1.0.0
|
mcp>=1.0.0
|
||||||
|
redis>=5.0.0
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,15 @@ If running standalone (without koi-net), creates its own app.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from spore_node.config import SporeConfig
|
from spore_node.config import SporeConfig
|
||||||
from spore_node.db.connection import init_pool, close_pool
|
from spore_node.db.connection import init_pool, close_pool
|
||||||
from spore_node.api.routers import health, holons, governance, claims, intents, commitments
|
from spore_node.api.routers import health, holons, governance, claims, intents, commitments, federation
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -23,6 +26,16 @@ def mount_routers(app: FastAPI) -> None:
|
||||||
app.include_router(claims.router)
|
app.include_router(claims.router)
|
||||||
app.include_router(intents.router)
|
app.include_router(intents.router)
|
||||||
app.include_router(commitments.router)
|
app.include_router(commitments.router)
|
||||||
|
app.include_router(federation.router)
|
||||||
|
|
||||||
|
# Graph visualization
|
||||||
|
static_dir = Path(__file__).parent.parent / "static"
|
||||||
|
if static_dir.exists():
|
||||||
|
@app.get("/graph")
|
||||||
|
async def graph_page():
|
||||||
|
return FileResponse(static_dir / "graph.html")
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
|
||||||
|
|
||||||
|
|
||||||
def create_standalone_app(cfg: SporeConfig | None = None) -> FastAPI:
|
def create_standalone_app(cfg: SporeConfig | None = None) -> FastAPI:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
"""Federation endpoints — peer handshake, trust tiers, event relay."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from spore_node.db.connection import get_pool
|
||||||
|
from spore_node.federation.crypto import (
|
||||||
|
generate_keypair, public_key_to_b64, b64_to_public_key, derive_shared_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/federation", tags=["federation"])
|
||||||
|
|
||||||
|
VALID_TRUST_TIERS = ("trusted", "peer", "monitored")
|
||||||
|
VALID_HANDSHAKE_STATES = ("pending", "proposed", "approved", "rejected")
|
||||||
|
|
||||||
|
# Node's own keypair (generated on first handshake)
|
||||||
|
_node_keypair: tuple[bytes, bytes] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_keypair() -> tuple[bytes, bytes]:
|
||||||
|
global _node_keypair
|
||||||
|
if _node_keypair is None:
|
||||||
|
_node_keypair = generate_keypair()
|
||||||
|
return _node_keypair
|
||||||
|
|
||||||
|
|
||||||
|
class HandshakeRequest(BaseModel):
|
||||||
|
peer_url: str
|
||||||
|
peer_rid: str = ""
|
||||||
|
peer_public_key: str = "" # base64-encoded X25519 public key
|
||||||
|
trust_tier: str = "monitored"
|
||||||
|
|
||||||
|
|
||||||
|
class PeerResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
node_rid: str
|
||||||
|
node_url: str
|
||||||
|
trust_tier: str
|
||||||
|
handshake_status: str
|
||||||
|
pending_events: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class HandshakeResponse(BaseModel):
|
||||||
|
status: str
|
||||||
|
peer_rid: str
|
||||||
|
our_public_key: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/handshake", response_model=HandshakeResponse)
|
||||||
|
async def initiate_handshake(data: HandshakeRequest):
|
||||||
|
"""Initiate or accept a federation handshake with a peer node."""
|
||||||
|
if data.trust_tier not in VALID_TRUST_TIERS:
|
||||||
|
raise HTTPException(422, f"trust_tier must be one of {VALID_TRUST_TIERS}")
|
||||||
|
|
||||||
|
pool = get_pool()
|
||||||
|
_, our_public = _get_keypair()
|
||||||
|
|
||||||
|
peer_rid = data.peer_rid or f"orn:koi-net.node:peer-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
# Store peer encryption config
|
||||||
|
config = {}
|
||||||
|
if data.peer_public_key:
|
||||||
|
config["peer_public_key"] = data.peer_public_key
|
||||||
|
config["our_public_key"] = public_key_to_b64(our_public)
|
||||||
|
# Derive shared key for this peer
|
||||||
|
our_private, _ = _get_keypair()
|
||||||
|
shared = derive_shared_key(our_private, b64_to_public_key(data.peer_public_key))
|
||||||
|
config["encryption_ready"] = True
|
||||||
|
|
||||||
|
try:
|
||||||
|
await pool.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO koi_peers (node_rid, node_url, trust_tier, handshake_status, config)
|
||||||
|
VALUES ($1, $2, $3, 'proposed', $4::jsonb)
|
||||||
|
ON CONFLICT (node_rid) DO UPDATE SET
|
||||||
|
node_url = EXCLUDED.node_url,
|
||||||
|
trust_tier = EXCLUDED.trust_tier,
|
||||||
|
handshake_status = 'proposed',
|
||||||
|
config = EXCLUDED.config,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
peer_rid, data.peer_url, data.trust_tier, json.dumps(config),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(500, f"Failed to store peer: {e}")
|
||||||
|
|
||||||
|
await _log_event(pool, peer_rid, "federation.handshake.proposed",
|
||||||
|
{"peer_url": data.peer_url, "trust_tier": data.trust_tier})
|
||||||
|
|
||||||
|
return HandshakeResponse(
|
||||||
|
status="proposed",
|
||||||
|
peer_rid=peer_rid,
|
||||||
|
our_public_key=public_key_to_b64(our_public),
|
||||||
|
message=f"Handshake proposed with {data.peer_url}. Awaiting bilateral approval.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/peers/{peer_rid}/approve")
|
||||||
|
async def approve_peer(peer_rid: str):
|
||||||
|
"""Approve a pending handshake — transitions to approved."""
|
||||||
|
pool = get_pool()
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT handshake_status FROM koi_peers WHERE node_rid = $1", peer_rid
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Peer not found")
|
||||||
|
if row["handshake_status"] == "approved":
|
||||||
|
return {"status": "already_approved"}
|
||||||
|
if row["handshake_status"] == "rejected":
|
||||||
|
raise HTTPException(422, "Peer was rejected. Re-initiate handshake.")
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE koi_peers SET handshake_status = 'approved', updated_at = now() WHERE node_rid = $1",
|
||||||
|
peer_rid,
|
||||||
|
)
|
||||||
|
await _log_event(pool, peer_rid, "federation.handshake.approved", {})
|
||||||
|
return {"status": "approved", "peer_rid": peer_rid}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/peers/{peer_rid}/reject")
|
||||||
|
async def reject_peer(peer_rid: str):
|
||||||
|
"""Reject a pending handshake."""
|
||||||
|
pool = get_pool()
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT handshake_status FROM koi_peers WHERE node_rid = $1", peer_rid
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Peer not found")
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE koi_peers SET handshake_status = 'rejected', updated_at = now() WHERE node_rid = $1",
|
||||||
|
peer_rid,
|
||||||
|
)
|
||||||
|
await _log_event(pool, peer_rid, "federation.handshake.rejected", {})
|
||||||
|
return {"status": "rejected", "peer_rid": peer_rid}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/peers/{peer_rid}/trust")
|
||||||
|
async def update_trust_tier(peer_rid: str, trust_tier: str):
|
||||||
|
"""Update a peer's trust tier."""
|
||||||
|
if trust_tier not in VALID_TRUST_TIERS:
|
||||||
|
raise HTTPException(422, f"trust_tier must be one of {VALID_TRUST_TIERS}")
|
||||||
|
|
||||||
|
pool = get_pool()
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT node_rid FROM koi_peers WHERE node_rid = $1", peer_rid
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Peer not found")
|
||||||
|
|
||||||
|
await pool.execute(
|
||||||
|
"UPDATE koi_peers SET trust_tier = $1, updated_at = now() WHERE node_rid = $2",
|
||||||
|
trust_tier, peer_rid,
|
||||||
|
)
|
||||||
|
await _log_event(pool, peer_rid, "federation.trust_tier.changed",
|
||||||
|
{"trust_tier": trust_tier})
|
||||||
|
return {"peer_rid": peer_rid, "trust_tier": trust_tier}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/peers", response_model=list[PeerResponse])
|
||||||
|
async def list_peers(trust_tier: str | None = None):
|
||||||
|
"""List all federation peers."""
|
||||||
|
pool = get_pool()
|
||||||
|
if trust_tier:
|
||||||
|
rows = await pool.fetch(
|
||||||
|
"SELECT * FROM koi_peers WHERE trust_tier = $1 ORDER BY created_at",
|
||||||
|
trust_tier,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rows = await pool.fetch("SELECT * FROM koi_peers ORDER BY created_at")
|
||||||
|
return [_peer_dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/peers/{peer_rid}", response_model=PeerResponse)
|
||||||
|
async def get_peer(peer_rid: str):
|
||||||
|
"""Get a single peer by RID."""
|
||||||
|
pool = get_pool()
|
||||||
|
row = await pool.fetchrow(
|
||||||
|
"SELECT * FROM koi_peers WHERE node_rid = $1", peer_rid
|
||||||
|
)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(404, "Peer not found")
|
||||||
|
return _peer_dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events/pending")
|
||||||
|
async def pending_events():
|
||||||
|
"""Get count of pending relay events per peer."""
|
||||||
|
pool = get_pool()
|
||||||
|
peers = await pool.fetch(
|
||||||
|
"SELECT node_rid, node_url FROM koi_peers WHERE handshake_status = 'approved'"
|
||||||
|
)
|
||||||
|
# Import relay lazily to avoid circular deps
|
||||||
|
from spore_node.federation.relay import FederationRelay
|
||||||
|
from spore_node.config import SporeConfig
|
||||||
|
cfg = SporeConfig()
|
||||||
|
relay = FederationRelay(cfg.redis_url)
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for peer in peers:
|
||||||
|
count = await relay.pending_count(peer["node_rid"])
|
||||||
|
if count > 0:
|
||||||
|
result[peer["node_rid"]] = count
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _peer_dict(row) -> dict:
|
||||||
|
d = dict(row)
|
||||||
|
d["id"] = str(d["id"])
|
||||||
|
d["pending_events"] = 0
|
||||||
|
d.pop("config", None)
|
||||||
|
d.pop("last_seen_at", None)
|
||||||
|
d.pop("created_at", None)
|
||||||
|
d.pop("updated_at", None)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
async def _log_event(pool, entity_rid: str, event_kind: str, payload: dict):
|
||||||
|
await pool.execute(
|
||||||
|
"INSERT INTO events (entity_rid, event_kind, payload) VALUES ($1, $2, $3::jsonb)",
|
||||||
|
entity_rid, event_kind, json.dumps(payload),
|
||||||
|
)
|
||||||
|
|
@ -6,6 +6,17 @@ from spore_node.db.connection import get_pool
|
||||||
router = APIRouter(tags=["health"])
|
router = APIRouter(tags=["health"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def root():
|
||||||
|
"""Root endpoint — redirect to docs."""
|
||||||
|
return {
|
||||||
|
"name": "Spore Agent Commons",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"docs": "/docs",
|
||||||
|
"health": "/health",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/health")
|
@router.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
"""Node health check with DB connectivity."""
|
"""Node health check with DB connectivity."""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
"""E2E encryption for federation payloads.
|
||||||
|
|
||||||
|
X25519 key exchange + ChaCha20-Poly1305 for symmetric encryption.
|
||||||
|
Uses the `cryptography` library (already a koi-net dependency).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.asymmetric.x25519 import (
|
||||||
|
X25519PrivateKey,
|
||||||
|
X25519PublicKey,
|
||||||
|
)
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
||||||
|
|
||||||
|
|
||||||
|
def generate_keypair() -> tuple[bytes, bytes]:
|
||||||
|
"""Generate X25519 keypair. Returns (private_key_bytes, public_key_bytes)."""
|
||||||
|
private_key = X25519PrivateKey.generate()
|
||||||
|
public_key = private_key.public_key()
|
||||||
|
return (
|
||||||
|
private_key.private_bytes_raw(),
|
||||||
|
public_key.public_bytes_raw(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def derive_shared_key(our_private: bytes, their_public: bytes) -> bytes:
|
||||||
|
"""Derive shared secret from X25519 key exchange."""
|
||||||
|
private_key = X25519PrivateKey.from_private_bytes(our_private)
|
||||||
|
public_key = X25519PublicKey.from_public_bytes(their_public)
|
||||||
|
return private_key.exchange(public_key)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(shared_key: bytes, plaintext: bytes) -> bytes:
|
||||||
|
"""Encrypt with ChaCha20-Poly1305. Returns nonce + ciphertext."""
|
||||||
|
aead = ChaCha20Poly1305(shared_key)
|
||||||
|
nonce = os.urandom(12)
|
||||||
|
ciphertext = aead.encrypt(nonce, plaintext, None)
|
||||||
|
return nonce + ciphertext
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(shared_key: bytes, data: bytes) -> bytes:
|
||||||
|
"""Decrypt ChaCha20-Poly1305. Expects nonce (12 bytes) + ciphertext."""
|
||||||
|
aead = ChaCha20Poly1305(shared_key)
|
||||||
|
nonce = data[:12]
|
||||||
|
ciphertext = data[12:]
|
||||||
|
return aead.decrypt(nonce, ciphertext, None)
|
||||||
|
|
||||||
|
|
||||||
|
def public_key_to_b64(key_bytes: bytes) -> str:
|
||||||
|
return base64.b64encode(key_bytes).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def b64_to_public_key(b64: str) -> bytes:
|
||||||
|
return base64.b64decode(b64)
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
"""Store-and-forward relay for offline federation peers.
|
||||||
|
|
||||||
|
Uses Redis sorted sets with TTL. Events queued per peer, flushed on schedule.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RELAY_PREFIX = "spore:relay:"
|
||||||
|
RELAY_TTL_SECONDS = 7 * 24 * 3600 # 7 days
|
||||||
|
|
||||||
|
|
||||||
|
class FederationRelay:
|
||||||
|
def __init__(self, redis_url: str):
|
||||||
|
self._redis_url = redis_url
|
||||||
|
self._redis = None
|
||||||
|
|
||||||
|
async def _get_redis(self):
|
||||||
|
if self._redis is None:
|
||||||
|
import redis.asyncio as aioredis
|
||||||
|
self._redis = aioredis.from_url(self._redis_url)
|
||||||
|
return self._redis
|
||||||
|
|
||||||
|
async def enqueue(self, peer_rid: str, event: dict) -> None:
|
||||||
|
"""Queue an event for a peer."""
|
||||||
|
r = await self._get_redis()
|
||||||
|
key = f"{RELAY_PREFIX}{peer_rid}"
|
||||||
|
score = time.time()
|
||||||
|
await r.zadd(key, {json.dumps(event): score})
|
||||||
|
# Set TTL on the key
|
||||||
|
await r.expire(key, RELAY_TTL_SECONDS)
|
||||||
|
|
||||||
|
async def flush_peer(self, peer_rid: str, peer_url: str) -> int:
|
||||||
|
"""Attempt to flush queued events to a peer. Returns count flushed."""
|
||||||
|
r = await self._get_redis()
|
||||||
|
key = f"{RELAY_PREFIX}{peer_rid}"
|
||||||
|
|
||||||
|
# Get all queued events
|
||||||
|
events = await r.zrangebyscore(key, "-inf", "+inf")
|
||||||
|
if not events:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
flushed = 0
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
for raw in events:
|
||||||
|
event = json.loads(raw)
|
||||||
|
try:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{peer_url}/koi-net/events/broadcast",
|
||||||
|
json=event,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
await r.zrem(key, raw)
|
||||||
|
flushed += 1
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Failed to flush to {peer_rid}: {e}")
|
||||||
|
break # Stop on first failure
|
||||||
|
|
||||||
|
return flushed
|
||||||
|
|
||||||
|
async def flush_all(self, peers: list[dict]) -> dict[str, int]:
|
||||||
|
"""Flush events to all peers. Returns {peer_rid: count}."""
|
||||||
|
results = {}
|
||||||
|
for peer in peers:
|
||||||
|
if peer.get("handshake_status") != "approved":
|
||||||
|
continue
|
||||||
|
count = await self.flush_peer(peer["node_rid"], peer["node_url"])
|
||||||
|
if count > 0:
|
||||||
|
results[peer["node_rid"]] = count
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def pending_count(self, peer_rid: str) -> int:
|
||||||
|
"""Get count of pending events for a peer."""
|
||||||
|
r = await self._get_redis()
|
||||||
|
key = f"{RELAY_PREFIX}{peer_rid}"
|
||||||
|
return await r.zcard(key)
|
||||||
|
|
||||||
|
async def prune_expired(self) -> int:
|
||||||
|
"""Remove events older than TTL. Returns count removed."""
|
||||||
|
r = await self._get_redis()
|
||||||
|
cutoff = time.time() - RELAY_TTL_SECONDS
|
||||||
|
total = 0
|
||||||
|
|
||||||
|
async for key in r.scan_iter(f"{RELAY_PREFIX}*"):
|
||||||
|
removed = await r.zremrangebyscore(key, "-inf", cutoff)
|
||||||
|
total += removed
|
||||||
|
|
||||||
|
return total
|
||||||
|
|
@ -366,5 +366,36 @@ def search_knowledge(query: str, entity_type: str = "",
|
||||||
return json.dumps(results, indent=2)
|
return json.dumps(results, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Federation tools ---
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def list_federation_peers(trust_tier: str = "") -> str:
|
||||||
|
"""List all federation peers with their trust tiers and handshake status.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
trust_tier: Filter by tier (trusted, peer, monitored). Empty = all.
|
||||||
|
"""
|
||||||
|
params = f"?trust_tier={trust_tier}" if trust_tier else ""
|
||||||
|
result = _api("GET", f"/federation/peers{params}")
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
@server.tool()
|
||||||
|
def initiate_federation_handshake(peer_url: str, peer_rid: str = "",
|
||||||
|
trust_tier: str = "monitored") -> str:
|
||||||
|
"""Initiate a federation handshake with a peer node.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
peer_url: URL of the peer node (e.g. 'https://bkc.example.com')
|
||||||
|
peer_rid: RID of the peer (optional, auto-generated if empty)
|
||||||
|
trust_tier: Initial trust tier (trusted, peer, monitored)
|
||||||
|
"""
|
||||||
|
result = _api("POST", "/federation/handshake", {
|
||||||
|
"peer_url": peer_url, "peer_rid": peer_rid,
|
||||||
|
"trust_tier": trust_tier,
|
||||||
|
})
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
server.run(transport="stdio")
|
server.run(transport="stdio")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Spore Agent Commons — Knowledge Graph</title>
|
||||||
|
<script src="https://d3js.org/d3.v7.min.js"></script>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { background: #0d1117; color: #c9d1d9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; overflow: hidden; }
|
||||||
|
#controls { position: fixed; top: 12px; left: 12px; z-index: 10; display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
.btn { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||||
|
.btn:hover { background: #30363d; }
|
||||||
|
.btn.active { background: #1f6feb; border-color: #388bfd; }
|
||||||
|
#info { position: fixed; bottom: 12px; left: 12px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 12px 16px; max-width: 360px; font-size: 13px; z-index: 10; }
|
||||||
|
#info h3 { color: #58a6ff; margin-bottom: 6px; }
|
||||||
|
#info .field { margin: 3px 0; }
|
||||||
|
#info .label { color: #8b949e; }
|
||||||
|
#legend { position: fixed; top: 12px; right: 12px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 10px 14px; font-size: 12px; z-index: 10; }
|
||||||
|
.legend-item { display: flex; align-items: center; gap: 6px; margin: 4px 0; }
|
||||||
|
.legend-dot { width: 12px; height: 12px; border-radius: 50%; }
|
||||||
|
svg { width: 100vw; height: 100vh; }
|
||||||
|
.link { stroke-opacity: 0.4; }
|
||||||
|
.node-label { font-size: 11px; fill: #8b949e; pointer-events: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="controls">
|
||||||
|
<button class="btn active" data-filter="all">All</button>
|
||||||
|
<button class="btn" data-filter="holon">Holons</button>
|
||||||
|
<button class="btn" data-filter="claim">Claims</button>
|
||||||
|
<button class="btn" data-filter="intent">Intents</button>
|
||||||
|
<button class="btn" data-filter="commitment">Commitments</button>
|
||||||
|
<button class="btn" data-filter="governance">Governance</button>
|
||||||
|
</div>
|
||||||
|
<div id="legend">
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div> Holon</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#f0883e"></div> Claim</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#3fb950"></div> Evidence</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#d2a8ff"></div> Intent</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#f778ba"></div> Commitment</div>
|
||||||
|
<div class="legend-item"><div class="legend-dot" style="background:#79c0ff"></div> Governance</div>
|
||||||
|
</div>
|
||||||
|
<div id="info"><h3>Spore Agent Commons</h3><p>Click a node to inspect</p></div>
|
||||||
|
<svg></svg>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const COLORS = {
|
||||||
|
holon: '#58a6ff', claim: '#f0883e', evidence: '#3fb950',
|
||||||
|
intent: '#d2a8ff', commitment: '#f778ba', governance: '#79c0ff',
|
||||||
|
attestation: '#ffa657', peer: '#56d364'
|
||||||
|
};
|
||||||
|
const SHAPES = {
|
||||||
|
holon: d3.symbolCircle, claim: d3.symbolSquare, evidence: d3.symbolTriangle,
|
||||||
|
intent: d3.symbolDiamond, commitment: d3.symbolStar, governance: d3.symbolCross,
|
||||||
|
};
|
||||||
|
const RADIUS = { holon: 10, claim: 8, evidence: 6, intent: 9, commitment: 10, governance: 8 };
|
||||||
|
|
||||||
|
let allNodes = [], allLinks = [], activeFilter = 'all';
|
||||||
|
|
||||||
|
async function fetchData() {
|
||||||
|
const [holons, claims, intents, commitments, dag] = await Promise.all([
|
||||||
|
fetch('/holons').then(r => r.json()),
|
||||||
|
fetch('/claims').then(r => r.json()),
|
||||||
|
fetch('/intents').then(r => r.json()),
|
||||||
|
fetch('/commitments').then(r => r.json()),
|
||||||
|
fetch('/governance/dag').then(r => r.json()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const nodes = [], links = [];
|
||||||
|
|
||||||
|
holons.forEach(h => nodes.push({ id: h.rid, label: h.name, kind: 'holon', data: h }));
|
||||||
|
claims.forEach(c => {
|
||||||
|
nodes.push({ id: c.rid, label: c.content.slice(0, 40), kind: 'claim', data: c });
|
||||||
|
if (c.proposer_rid) links.push({ source: c.proposer_rid, target: c.rid, type: 'proposed' });
|
||||||
|
});
|
||||||
|
intents.forEach(i => {
|
||||||
|
nodes.push({ id: i.rid, label: i.title, kind: 'intent', data: i });
|
||||||
|
if (i.publisher_rid) links.push({ source: i.publisher_rid, target: i.rid, type: 'published' });
|
||||||
|
});
|
||||||
|
commitments.forEach(c => {
|
||||||
|
nodes.push({ id: c.rid, label: c.title, kind: 'commitment', data: c });
|
||||||
|
if (c.proposer_rid) links.push({ source: c.proposer_rid, target: c.rid, type: 'proposed' });
|
||||||
|
if (c.acceptor_rid) links.push({ source: c.acceptor_rid, target: c.rid, type: 'accepted' });
|
||||||
|
});
|
||||||
|
dag.nodes.forEach(d => {
|
||||||
|
nodes.push({ id: `gov:${d.doc_id}`, label: d.title || d.doc_id, kind: 'governance', data: d });
|
||||||
|
d.parents.forEach(p => links.push({ source: `gov:${p}`, target: `gov:${d.doc_id}`, type: 'depends_on' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out links with missing nodes
|
||||||
|
const nodeIds = new Set(nodes.map(n => n.id));
|
||||||
|
const validLinks = links.filter(l => nodeIds.has(l.source) && nodeIds.has(l.target));
|
||||||
|
|
||||||
|
return { nodes, links: validLinks };
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(nodes, links) {
|
||||||
|
const svg = d3.select('svg');
|
||||||
|
svg.selectAll('*').remove();
|
||||||
|
const w = window.innerWidth, h = window.innerHeight;
|
||||||
|
const g = svg.append('g');
|
||||||
|
|
||||||
|
svg.call(d3.zoom().scaleExtent([0.2, 5]).on('zoom', e => g.attr('transform', e.transform)));
|
||||||
|
|
||||||
|
const sim = d3.forceSimulation(nodes)
|
||||||
|
.force('link', d3.forceLink(links).id(d => d.id).distance(80))
|
||||||
|
.force('charge', d3.forceManyBody().strength(-200))
|
||||||
|
.force('center', d3.forceCenter(w / 2, h / 2))
|
||||||
|
.force('collision', d3.forceCollide().radius(20));
|
||||||
|
|
||||||
|
const link = g.selectAll('.link').data(links).enter().append('line')
|
||||||
|
.attr('class', 'link')
|
||||||
|
.attr('stroke', '#30363d')
|
||||||
|
.attr('stroke-width', 1.5);
|
||||||
|
|
||||||
|
const node = g.selectAll('.node').data(nodes).enter().append('g')
|
||||||
|
.attr('class', 'node')
|
||||||
|
.call(d3.drag().on('start', dragStart).on('drag', dragging).on('end', dragEnd));
|
||||||
|
|
||||||
|
node.append('path')
|
||||||
|
.attr('d', d => d3.symbol().type(SHAPES[d.kind] || d3.symbolCircle).size((RADIUS[d.kind] || 8) ** 2 * 3)())
|
||||||
|
.attr('fill', d => COLORS[d.kind] || '#8b949e')
|
||||||
|
.attr('stroke', '#0d1117')
|
||||||
|
.attr('stroke-width', 1.5)
|
||||||
|
.style('cursor', 'pointer');
|
||||||
|
|
||||||
|
node.append('text')
|
||||||
|
.attr('class', 'node-label')
|
||||||
|
.attr('dx', 14).attr('dy', 4)
|
||||||
|
.text(d => d.label.length > 30 ? d.label.slice(0, 30) + '…' : d.label);
|
||||||
|
|
||||||
|
node.on('click', (e, d) => {
|
||||||
|
const info = document.getElementById('info');
|
||||||
|
info.innerHTML = `<h3>${d.label}</h3>
|
||||||
|
<div class="field"><span class="label">Kind:</span> ${d.kind}</div>
|
||||||
|
<div class="field"><span class="label">RID:</span> ${d.id}</div>
|
||||||
|
${d.data.status ? `<div class="field"><span class="label">Status:</span> ${d.data.status}</div>` : ''}
|
||||||
|
${d.data.state ? `<div class="field"><span class="label">State:</span> ${d.data.state}</div>` : ''}
|
||||||
|
${d.data.holon_type ? `<div class="field"><span class="label">Type:</span> ${d.data.holon_type}</div>` : ''}
|
||||||
|
${d.data.intent_type ? `<div class="field"><span class="label">Intent:</span> ${d.data.intent_type}</div>` : ''}
|
||||||
|
${d.data.confidence !== undefined ? `<div class="field"><span class="label">Confidence:</span> ${d.data.confidence}</div>` : ''}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
sim.on('tick', () => {
|
||||||
|
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
||||||
|
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
||||||
|
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||||
|
});
|
||||||
|
|
||||||
|
function dragStart(e, d) { if (!e.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; }
|
||||||
|
function dragging(e, d) { d.fx = e.x; d.fy = e.y; }
|
||||||
|
function dragEnd(e, d) { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter(filter) {
|
||||||
|
activeFilter = filter;
|
||||||
|
document.querySelectorAll('.btn').forEach(b => b.classList.toggle('active', b.dataset.filter === filter));
|
||||||
|
const nodes = filter === 'all' ? allNodes : allNodes.filter(n => n.kind === filter);
|
||||||
|
const nodeIds = new Set(nodes.map(n => n.id));
|
||||||
|
const links = allLinks.filter(l => {
|
||||||
|
const sId = typeof l.source === 'object' ? l.source.id : l.source;
|
||||||
|
const tId = typeof l.target === 'object' ? l.target.id : l.target;
|
||||||
|
return nodeIds.has(sId) && nodeIds.has(tId);
|
||||||
|
});
|
||||||
|
// Deep clone to avoid D3 mutation issues
|
||||||
|
render(nodes.map(n => ({...n})), links.map(l => ({source: typeof l.source === 'object' ? l.source.id : l.source, target: typeof l.target === 'object' ? l.target.id : l.target, type: l.type})));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.btn').forEach(b => b.addEventListener('click', () => applyFilter(b.dataset.filter)));
|
||||||
|
|
||||||
|
fetchData().then(({ nodes, links }) => {
|
||||||
|
allNodes = nodes;
|
||||||
|
allLinks = links;
|
||||||
|
render(nodes, links);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue