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
|
||||
pyyaml>=6.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
|
||||
from pathlib import Path
|
||||
from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from spore_node.config import SporeConfig
|
||||
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__)
|
||||
|
||||
|
|
@ -23,6 +26,16 @@ def mount_routers(app: FastAPI) -> None:
|
|||
app.include_router(claims.router)
|
||||
app.include_router(intents.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:
|
||||
|
|
|
|||
|
|
@ -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.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")
|
||||
async def health():
|
||||
"""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)
|
||||
|
||||
|
||||
# --- 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__":
|
||||
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