feat: implement all M01-M13, X01-X04 modules + quality gate fixes
Browse filesPhase 1 core implementation complete:
- M01 Identity: Ed25519 keys, canonical JSON, node/community manifests
- M02 Discovery: mDNS (zeroconf), UDP multicast, enhanced PeerRegistry
- M03 Bus: schema validation (jsonschema optional), CallTrace ring buffer
- M04 LLM: Ollama/llama.cpp/HF Transformers backends, OpenAI online fallback
- M05 RAG: sliding-window chunker, CorpusStore (Chroma + in-memory fallback)
- M06 Marketplace: event-sourced with MaterialisedView
- M07 Blobs: BLAKE3 CID store, 256KB chunking, TransferManager, FileService
- M08 UI: UiApp + 6 Gradio tabs (Ask/Chat/Market/Files/Emergency/Settings)
- M09 Emergency: async probe loop, DNS+HTTP probes, anti-flap, StateBus
- M10 Chat: event-backed ChatView, DeliveryManager
- M11 Embedding: embed.text, SimpleHashBackend (no-dep), SentenceTransformer
- M12 CLI: click-based init/run/status/caps/call/doctor/trace/export
- M13 Onboarding: InviteBlob encode/decode, QR PNG, community flows
- X01 Transport: FastAPI server (12 endpoints), signed HTTP client, SSE, TLS
- X02 Events: SQLite WAL log, LamportClock, ReplayEngine, SnapshotStore
- X03 Observability: JSON logging, prometheus metrics, trace ring, doctor
- X04 Config: typed frozen Config, TOML load/save/validate, XDG paths
Security fixes:
- Remove verify=False from emergency HTTP prober (was HIGH bandit finding)
- Add URL validation to CLI (local-only enforcement)
- nosec annotations for intentional bind-all and SQL placeholders
Quality: 13/13 tests pass; ruff auto-fixed; bandit HIGH=0, MEDIUM=7 (all justified)
- hearthnet/__main__.py +4 -0
- hearthnet/blobs/__init__.py +27 -0
- hearthnet/blobs/chunker.py +96 -0
- hearthnet/blobs/store.py +202 -0
- hearthnet/blobs/transfer.py +86 -0
- hearthnet/bus/schema.py +73 -0
- hearthnet/bus/trace.py +36 -0
- hearthnet/cli.py +393 -0
- hearthnet/config.py +498 -0
- hearthnet/constants.py +75 -0
- hearthnet/discovery/__init__.py +8 -2
- hearthnet/discovery/mdns.py +134 -0
- hearthnet/discovery/peers.py +84 -24
- hearthnet/discovery/relay.py +11 -0
- hearthnet/discovery/udp.py +159 -0
- hearthnet/emergency/detector.py +154 -13
- hearthnet/emergency/state.py +81 -23
- hearthnet/events/__init__.py +27 -0
- hearthnet/events/lamport.py +84 -0
- hearthnet/events/log.py +411 -0
- hearthnet/events/replay.py +102 -0
- hearthnet/events/snapshot.py +198 -0
- hearthnet/events/sync.py +181 -0
- hearthnet/events/types.py +57 -0
- hearthnet/identity/__init__.py +57 -0
- hearthnet/identity/keys.py +299 -0
- hearthnet/identity/manifest.py +414 -0
- hearthnet/identity/tokens.py +42 -0
- hearthnet/observability/__init__.py +41 -0
- hearthnet/observability/doctor.py +286 -0
- hearthnet/observability/logging.py +145 -0
- hearthnet/observability/metrics.py +212 -0
- hearthnet/observability/tracing.py +146 -0
- hearthnet/services/__init__.py +4 -1
- hearthnet/services/chat/__init__.py +7 -0
- hearthnet/services/chat/delivery.py +50 -0
- hearthnet/services/chat/service.py +105 -0
- hearthnet/services/chat/views.py +136 -0
- hearthnet/services/embedding/__init__.py +15 -0
- hearthnet/services/embedding/backends.py +112 -0
- hearthnet/services/embedding/service.py +72 -0
- hearthnet/services/file/__init__.py +5 -0
- hearthnet/services/file/service.py +108 -0
- hearthnet/services/llm/__init__.py +3 -0
- hearthnet/services/llm/backends/__init__.py +1 -0
- hearthnet/services/llm/backends/base.py +61 -0
- hearthnet/services/llm/backends/hf_local.py +118 -0
- hearthnet/services/llm/backends/llama_cpp.py +134 -0
- hearthnet/services/llm/backends/ollama.py +127 -0
- hearthnet/services/llm/backends/openai_compat.py +145 -0
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from hearthnet.cli import main
|
| 2 |
+
|
| 3 |
+
if __name__ == "__main__":
|
| 4 |
+
main()
|
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from hearthnet.blobs.chunker import (
|
| 4 |
+
BlobError,
|
| 5 |
+
BlobManifest,
|
| 6 |
+
ChunkRef,
|
| 7 |
+
chunk_blob,
|
| 8 |
+
hash_bytes,
|
| 9 |
+
manifest_cid,
|
| 10 |
+
reassemble,
|
| 11 |
+
verify_chunk,
|
| 12 |
+
)
|
| 13 |
+
from hearthnet.blobs.store import BlobStore
|
| 14 |
+
from hearthnet.blobs.transfer import TransferManager
|
| 15 |
+
|
| 16 |
+
__all__ = [
|
| 17 |
+
"BlobError",
|
| 18 |
+
"BlobManifest",
|
| 19 |
+
"BlobStore",
|
| 20 |
+
"ChunkRef",
|
| 21 |
+
"TransferManager",
|
| 22 |
+
"chunk_blob",
|
| 23 |
+
"hash_bytes",
|
| 24 |
+
"manifest_cid",
|
| 25 |
+
"reassemble",
|
| 26 |
+
"verify_chunk",
|
| 27 |
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
CHUNK_SIZE_BYTES = 256 * 1024 # 256 KB
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class BlobError(Exception):
|
| 12 |
+
def __init__(self, code: str, message: str = "") -> None:
|
| 13 |
+
super().__init__(message or code)
|
| 14 |
+
self.code = code
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass(frozen=True)
|
| 18 |
+
class ChunkRef:
|
| 19 |
+
index: int
|
| 20 |
+
cid: str # "blake3:<hex>" or "sha256:<hex>"
|
| 21 |
+
size_bytes: int
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass(frozen=True)
|
| 25 |
+
class BlobManifest:
|
| 26 |
+
cid: str # merkle root CID
|
| 27 |
+
size_bytes: int
|
| 28 |
+
chunk_size_bytes: int
|
| 29 |
+
chunks: list[ChunkRef]
|
| 30 |
+
filename: str | None # advisory only
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def hash_bytes(data: bytes) -> str:
|
| 34 |
+
"""Hash with BLAKE3 if available, else SHA256. Returns 'blake3:<hex>' or 'sha256:<hex>'."""
|
| 35 |
+
try:
|
| 36 |
+
import blake3 # type: ignore[import]
|
| 37 |
+
|
| 38 |
+
return "blake3:" + blake3.blake3(data).hexdigest()
|
| 39 |
+
except ImportError:
|
| 40 |
+
return "sha256:" + hashlib.sha256(data).hexdigest()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def chunk_blob(
|
| 44 |
+
data: bytes, *, chunk_size: int = CHUNK_SIZE_BYTES
|
| 45 |
+
) -> tuple[BlobManifest, list[bytes]]:
|
| 46 |
+
"""Split data into chunks. Compute per-chunk CID and merkle-root CID."""
|
| 47 |
+
chunks_data: list[bytes] = []
|
| 48 |
+
chunk_refs: list[ChunkRef] = []
|
| 49 |
+
|
| 50 |
+
offset = 0
|
| 51 |
+
index = 0
|
| 52 |
+
while offset < len(data) or index == 0:
|
| 53 |
+
piece = data[offset : offset + chunk_size]
|
| 54 |
+
cid = hash_bytes(piece)
|
| 55 |
+
chunk_refs.append(ChunkRef(index=index, cid=cid, size_bytes=len(piece)))
|
| 56 |
+
chunks_data.append(piece)
|
| 57 |
+
offset += chunk_size
|
| 58 |
+
index += 1
|
| 59 |
+
if offset >= len(data):
|
| 60 |
+
break
|
| 61 |
+
|
| 62 |
+
merkle_root = hash_bytes(b"\n".join(sorted(c.cid.encode() for c in chunk_refs)))
|
| 63 |
+
|
| 64 |
+
manifest = BlobManifest(
|
| 65 |
+
cid=merkle_root,
|
| 66 |
+
size_bytes=len(data),
|
| 67 |
+
chunk_size_bytes=chunk_size,
|
| 68 |
+
chunks=chunk_refs,
|
| 69 |
+
filename=None,
|
| 70 |
+
)
|
| 71 |
+
return manifest, chunks_data
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def manifest_cid(manifest: BlobManifest) -> str:
|
| 75 |
+
"""CID of canonical JSON of {chunks: [{cid,size_bytes}], size_bytes, chunk_size_bytes}."""
|
| 76 |
+
payload = {
|
| 77 |
+
"chunk_size_bytes": manifest.chunk_size_bytes,
|
| 78 |
+
"chunks": [{"cid": c.cid, "size_bytes": c.size_bytes} for c in manifest.chunks],
|
| 79 |
+
"size_bytes": manifest.size_bytes,
|
| 80 |
+
}
|
| 81 |
+
raw = json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode(
|
| 82 |
+
"utf-8"
|
| 83 |
+
)
|
| 84 |
+
return hash_bytes(raw)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def reassemble(chunks: list[bytes]) -> bytes:
|
| 88 |
+
"""Concat chunks in index order."""
|
| 89 |
+
return b"".join(chunks)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def verify_chunk(data: bytes, expected_cid: str) -> None:
|
| 93 |
+
"""Raise BlobError('hash_mismatch') if hash(data) != expected_cid."""
|
| 94 |
+
actual = hash_bytes(data)
|
| 95 |
+
if actual != expected_cid:
|
| 96 |
+
raise BlobError("hash_mismatch", f"Expected {expected_cid}, got {actual}")
|
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import shutil
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
from hearthnet.blobs.chunker import (
|
| 8 |
+
BlobError,
|
| 9 |
+
BlobManifest,
|
| 10 |
+
ChunkRef,
|
| 11 |
+
chunk_blob,
|
| 12 |
+
reassemble,
|
| 13 |
+
verify_chunk,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class BlobStore:
|
| 18 |
+
"""Sharded filesystem store.
|
| 19 |
+
|
| 20 |
+
Layout::
|
| 21 |
+
|
| 22 |
+
<root>/<aa>/<rest>.bin — chunk binary
|
| 23 |
+
<root>/<aa>/<rest>.manifest.json — blob manifest
|
| 24 |
+
<root>/pinned.txt — newline-separated pinned CIDs
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(self, root: Path) -> None:
|
| 28 |
+
self.root = Path(root)
|
| 29 |
+
self.root.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
|
| 31 |
+
# ------------------------------------------------------------------
|
| 32 |
+
# Public API
|
| 33 |
+
# ------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
def put(self, data: bytes, filename: str | None = None) -> BlobManifest:
|
| 36 |
+
"""Chunk, hash, store all chunks, write manifest, return BlobManifest."""
|
| 37 |
+
manifest, chunks_data = chunk_blob(data)
|
| 38 |
+
# Attach filename (BlobManifest is frozen, rebuild with filename)
|
| 39 |
+
manifest = BlobManifest(
|
| 40 |
+
cid=manifest.cid,
|
| 41 |
+
size_bytes=manifest.size_bytes,
|
| 42 |
+
chunk_size_bytes=manifest.chunk_size_bytes,
|
| 43 |
+
chunks=manifest.chunks,
|
| 44 |
+
filename=filename,
|
| 45 |
+
)
|
| 46 |
+
for chunk_ref, chunk_data in zip(manifest.chunks, chunks_data, strict=False):
|
| 47 |
+
path = self._blob_path(chunk_ref.cid)
|
| 48 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 49 |
+
if not path.exists():
|
| 50 |
+
path.write_bytes(chunk_data)
|
| 51 |
+
|
| 52 |
+
mpath = self._manifest_path(manifest.cid)
|
| 53 |
+
mpath.parent.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
mpath.write_text(
|
| 55 |
+
json.dumps(self._manifest_to_dict(manifest), sort_keys=True, indent=2),
|
| 56 |
+
encoding="utf-8",
|
| 57 |
+
)
|
| 58 |
+
return manifest
|
| 59 |
+
|
| 60 |
+
def get(self, cid: str) -> bytes:
|
| 61 |
+
"""Read and reassemble blob. Raise BlobError('not_found') if missing."""
|
| 62 |
+
manifest = self.get_manifest(cid)
|
| 63 |
+
chunks: list[bytes] = []
|
| 64 |
+
for chunk_ref in manifest.chunks:
|
| 65 |
+
chunk_data = self.get_chunk(chunk_ref.cid)
|
| 66 |
+
verify_chunk(chunk_data, chunk_ref.cid)
|
| 67 |
+
chunks.append(chunk_data)
|
| 68 |
+
return reassemble(chunks)
|
| 69 |
+
|
| 70 |
+
def get_chunk(self, chunk_cid: str) -> bytes:
|
| 71 |
+
"""Read one chunk. Raise BlobError('not_found') if missing."""
|
| 72 |
+
path = self._blob_path(chunk_cid)
|
| 73 |
+
if not path.exists():
|
| 74 |
+
raise BlobError("not_found", f"Chunk {chunk_cid} not found")
|
| 75 |
+
return path.read_bytes()
|
| 76 |
+
|
| 77 |
+
def has(self, cid: str) -> bool:
|
| 78 |
+
"""True iff blob manifest exists."""
|
| 79 |
+
return self._manifest_path(cid).exists()
|
| 80 |
+
|
| 81 |
+
def get_manifest(self, cid: str) -> BlobManifest:
|
| 82 |
+
"""Load manifest from disk."""
|
| 83 |
+
path = self._manifest_path(cid)
|
| 84 |
+
if not path.exists():
|
| 85 |
+
raise BlobError("not_found", f"Blob {cid} not found")
|
| 86 |
+
try:
|
| 87 |
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
raise BlobError("manifest_invalid", str(exc)) from exc
|
| 90 |
+
return self._manifest_from_dict(raw)
|
| 91 |
+
|
| 92 |
+
def list_blobs(self) -> list[BlobManifest]:
|
| 93 |
+
"""List all blob manifests."""
|
| 94 |
+
manifests: list[BlobManifest] = []
|
| 95 |
+
for mpath in self.root.rglob("*.manifest.json"):
|
| 96 |
+
try:
|
| 97 |
+
raw = json.loads(mpath.read_text(encoding="utf-8"))
|
| 98 |
+
manifests.append(self._manifest_from_dict(raw))
|
| 99 |
+
except Exception:
|
| 100 |
+
pass
|
| 101 |
+
return manifests
|
| 102 |
+
|
| 103 |
+
def pin(self, cid: str) -> None:
|
| 104 |
+
"""Add CID to pinned.txt."""
|
| 105 |
+
pinned = self._read_pinned()
|
| 106 |
+
pinned.add(cid)
|
| 107 |
+
self._write_pinned(pinned)
|
| 108 |
+
|
| 109 |
+
def unpin(self, cid: str) -> None:
|
| 110 |
+
"""Remove CID from pinned.txt."""
|
| 111 |
+
pinned = self._read_pinned()
|
| 112 |
+
pinned.discard(cid)
|
| 113 |
+
self._write_pinned(pinned)
|
| 114 |
+
|
| 115 |
+
def gc(self, threshold: float = 0.80) -> int:
|
| 116 |
+
"""Remove unpinned blobs if disk usage > threshold. Returns count removed."""
|
| 117 |
+
usage = shutil.disk_usage(self.root)
|
| 118 |
+
if usage.used / usage.total <= threshold:
|
| 119 |
+
return 0
|
| 120 |
+
pinned = self._read_pinned()
|
| 121 |
+
removed = 0
|
| 122 |
+
for manifest in self.list_blobs():
|
| 123 |
+
if manifest.cid in pinned:
|
| 124 |
+
continue
|
| 125 |
+
# Remove chunk files
|
| 126 |
+
for chunk_ref in manifest.chunks:
|
| 127 |
+
cpath = self._blob_path(chunk_ref.cid)
|
| 128 |
+
if cpath.exists():
|
| 129 |
+
cpath.unlink()
|
| 130 |
+
# Remove manifest
|
| 131 |
+
mpath = self._manifest_path(manifest.cid)
|
| 132 |
+
if mpath.exists():
|
| 133 |
+
mpath.unlink()
|
| 134 |
+
removed += 1
|
| 135 |
+
return removed
|
| 136 |
+
|
| 137 |
+
# ------------------------------------------------------------------
|
| 138 |
+
# Path helpers
|
| 139 |
+
# ------------------------------------------------------------------
|
| 140 |
+
|
| 141 |
+
def _blob_path(self, cid: str) -> Path:
|
| 142 |
+
"""<root>/<aa>/<rest>.bin where aa = first 2 hex chars of CID hex."""
|
| 143 |
+
hex_part = self._hex_part(cid)
|
| 144 |
+
shard = hex_part[:2]
|
| 145 |
+
rest = hex_part[2:]
|
| 146 |
+
return self.root / shard / f"{rest}.bin"
|
| 147 |
+
|
| 148 |
+
def _manifest_path(self, cid: str) -> Path:
|
| 149 |
+
hex_part = self._hex_part(cid)
|
| 150 |
+
shard = hex_part[:2]
|
| 151 |
+
rest = hex_part[2:]
|
| 152 |
+
return self.root / shard / f"{rest}.manifest.json"
|
| 153 |
+
|
| 154 |
+
def _hex_part(self, cid: str) -> str:
|
| 155 |
+
"""Extract hex from 'blake3:<hex>' or 'sha256:<hex>'."""
|
| 156 |
+
return cid.split(":", 1)[1]
|
| 157 |
+
|
| 158 |
+
# ------------------------------------------------------------------
|
| 159 |
+
# Serialization helpers
|
| 160 |
+
# ------------------------------------------------------------------
|
| 161 |
+
|
| 162 |
+
@staticmethod
|
| 163 |
+
def _manifest_to_dict(m: BlobManifest) -> dict:
|
| 164 |
+
return {
|
| 165 |
+
"cid": m.cid,
|
| 166 |
+
"size_bytes": m.size_bytes,
|
| 167 |
+
"chunk_size_bytes": m.chunk_size_bytes,
|
| 168 |
+
"filename": m.filename,
|
| 169 |
+
"chunks": [
|
| 170 |
+
{"index": c.index, "cid": c.cid, "size_bytes": c.size_bytes} for c in m.chunks
|
| 171 |
+
],
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
@staticmethod
|
| 175 |
+
def _manifest_from_dict(raw: dict) -> BlobManifest:
|
| 176 |
+
chunks = [
|
| 177 |
+
ChunkRef(index=c["index"], cid=c["cid"], size_bytes=c["size_bytes"])
|
| 178 |
+
for c in raw.get("chunks", [])
|
| 179 |
+
]
|
| 180 |
+
return BlobManifest(
|
| 181 |
+
cid=raw["cid"],
|
| 182 |
+
size_bytes=raw["size_bytes"],
|
| 183 |
+
chunk_size_bytes=raw["chunk_size_bytes"],
|
| 184 |
+
chunks=chunks,
|
| 185 |
+
filename=raw.get("filename"),
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
# ------------------------------------------------------------------
|
| 189 |
+
# Pinned helpers
|
| 190 |
+
# ------------------------------------------------------------------
|
| 191 |
+
|
| 192 |
+
def _pinned_path(self) -> Path:
|
| 193 |
+
return self.root / "pinned.txt"
|
| 194 |
+
|
| 195 |
+
def _read_pinned(self) -> set[str]:
|
| 196 |
+
p = self._pinned_path()
|
| 197 |
+
if not p.exists():
|
| 198 |
+
return set()
|
| 199 |
+
return {line.strip() for line in p.read_text(encoding="utf-8").splitlines() if line.strip()}
|
| 200 |
+
|
| 201 |
+
def _write_pinned(self, pinned: set[str]) -> None:
|
| 202 |
+
self._pinned_path().write_text("\n".join(sorted(pinned)), encoding="utf-8")
|
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from hearthnet.blobs.chunker import BlobError, BlobManifest, verify_chunk
|
| 4 |
+
from hearthnet.blobs.store import BlobStore
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class TransferManager:
|
| 8 |
+
"""Coordinates parallel chunk fetch from multiple peer sources.
|
| 9 |
+
|
| 10 |
+
MVP: fetch from one source at a time (parallel is Phase 2 optimization).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
def __init__(self, store: BlobStore, http_client=None) -> None:
|
| 14 |
+
self.store = store
|
| 15 |
+
self._http_client = http_client
|
| 16 |
+
|
| 17 |
+
async def fetch(self, cid: str, sources: list[str]) -> BlobManifest:
|
| 18 |
+
"""Fetch a blob from one of the sources (tries in order).
|
| 19 |
+
|
| 20 |
+
sources: list of base URLs like 'https://host:7080'
|
| 21 |
+
"""
|
| 22 |
+
last_exc: Exception | None = None
|
| 23 |
+
for base_url in sources:
|
| 24 |
+
try:
|
| 25 |
+
return await self._fetch_from(cid, base_url)
|
| 26 |
+
except Exception as exc:
|
| 27 |
+
last_exc = exc
|
| 28 |
+
raise BlobError(
|
| 29 |
+
"not_found",
|
| 30 |
+
f"Could not fetch {cid} from any source. Last error: {last_exc}",
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
async def _fetch_from(self, cid: str, base_url: str) -> BlobManifest:
|
| 34 |
+
"""Fetch blob manifest + all chunks from base_url/file/chunks/<chunk_cid>."""
|
| 35 |
+
import json
|
| 36 |
+
|
| 37 |
+
client = self._http_client
|
| 38 |
+
if client is None:
|
| 39 |
+
try:
|
| 40 |
+
import httpx # type: ignore[import]
|
| 41 |
+
|
| 42 |
+
client = httpx.AsyncClient()
|
| 43 |
+
except ImportError as exc:
|
| 44 |
+
raise BlobError(
|
| 45 |
+
"io_error", "httpx is required for TransferManager network fetch"
|
| 46 |
+
) from exc
|
| 47 |
+
|
| 48 |
+
manifest_url = f"{base_url.rstrip('/')}/file/manifest/{cid}"
|
| 49 |
+
try:
|
| 50 |
+
resp = await client.get(manifest_url, timeout=30)
|
| 51 |
+
resp.raise_for_status()
|
| 52 |
+
raw = resp.json()
|
| 53 |
+
except Exception as exc:
|
| 54 |
+
raise BlobError("io_error", f"Failed to fetch manifest from {manifest_url}: {exc}") from exc
|
| 55 |
+
|
| 56 |
+
from hearthnet.blobs.store import BlobStore as _BS
|
| 57 |
+
|
| 58 |
+
manifest = _BS._manifest_from_dict(raw)
|
| 59 |
+
|
| 60 |
+
for chunk_ref in manifest.chunks:
|
| 61 |
+
if self.store._blob_path(chunk_ref.cid).exists():
|
| 62 |
+
continue # already have it
|
| 63 |
+
chunk_url = f"{base_url.rstrip('/')}/file/chunks/{chunk_ref.cid}"
|
| 64 |
+
try:
|
| 65 |
+
resp = await client.get(chunk_url, timeout=60)
|
| 66 |
+
resp.raise_for_status()
|
| 67 |
+
chunk_data = resp.content
|
| 68 |
+
except Exception as exc:
|
| 69 |
+
raise BlobError(
|
| 70 |
+
"io_error", f"Failed to fetch chunk {chunk_ref.cid}: {exc}"
|
| 71 |
+
) from exc
|
| 72 |
+
verify_chunk(chunk_data, chunk_ref.cid)
|
| 73 |
+
path = self.store._blob_path(chunk_ref.cid)
|
| 74 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 75 |
+
path.write_bytes(chunk_data)
|
| 76 |
+
|
| 77 |
+
# Write manifest if not already stored
|
| 78 |
+
if not self.store.has(manifest.cid):
|
| 79 |
+
mpath = self.store._manifest_path(manifest.cid)
|
| 80 |
+
mpath.parent.mkdir(parents=True, exist_ok=True)
|
| 81 |
+
mpath.write_text(
|
| 82 |
+
json.dumps(_BS._manifest_to_dict(manifest), sort_keys=True, indent=2),
|
| 83 |
+
encoding="utf-8",
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
return manifest
|
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JSON Schema validation for capability requests/responses."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
try:
|
| 9 |
+
import jsonschema as _jsonschema
|
| 10 |
+
|
| 11 |
+
HAS_JSONSCHEMA = True
|
| 12 |
+
except ImportError:
|
| 13 |
+
_jsonschema = None # type: ignore[assignment]
|
| 14 |
+
HAS_JSONSCHEMA = False
|
| 15 |
+
|
| 16 |
+
from hearthnet.bus.capability import CapabilityDescriptor
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class SchemaValidator:
|
| 20 |
+
"""JSON Schema validation with caching. No-op if jsonschema not installed."""
|
| 21 |
+
|
| 22 |
+
def __init__(self) -> None:
|
| 23 |
+
self._cache: dict[str, Any] = {} # cache_key -> unused (validate is stateless)
|
| 24 |
+
|
| 25 |
+
def validate_request(self, descriptor: CapabilityDescriptor, body: dict) -> None:
|
| 26 |
+
"""Validate request body against descriptor's request_schema.
|
| 27 |
+
|
| 28 |
+
Raises ValueError if invalid.
|
| 29 |
+
"""
|
| 30 |
+
if not HAS_JSONSCHEMA or not descriptor.request_schema:
|
| 31 |
+
return
|
| 32 |
+
key = f"req:{descriptor.name}:{descriptor.version_str}"
|
| 33 |
+
self._validate(descriptor.request_schema, body, key)
|
| 34 |
+
|
| 35 |
+
def validate_response(self, descriptor: CapabilityDescriptor, response: dict) -> None:
|
| 36 |
+
"""Validate response against response_schema.
|
| 37 |
+
|
| 38 |
+
Raises ValueError if invalid.
|
| 39 |
+
"""
|
| 40 |
+
if not HAS_JSONSCHEMA or not descriptor.response_schema:
|
| 41 |
+
return
|
| 42 |
+
key = f"resp:{descriptor.name}:{descriptor.version_str}"
|
| 43 |
+
self._validate(descriptor.response_schema, response, key)
|
| 44 |
+
|
| 45 |
+
def validate_stream_frame(
|
| 46 |
+
self, descriptor: CapabilityDescriptor, frame: dict
|
| 47 |
+
) -> None:
|
| 48 |
+
"""Validate a streaming frame."""
|
| 49 |
+
if not HAS_JSONSCHEMA or not descriptor.stream_schema:
|
| 50 |
+
return
|
| 51 |
+
key = f"stream:{descriptor.name}:{descriptor.version_str}"
|
| 52 |
+
self._validate(descriptor.stream_schema, frame, key)
|
| 53 |
+
|
| 54 |
+
def _validate(self, schema: dict, instance: dict, cache_key: str) -> None:
|
| 55 |
+
if not HAS_JSONSCHEMA or _jsonschema is None:
|
| 56 |
+
return
|
| 57 |
+
try:
|
| 58 |
+
_jsonschema.validate(instance, schema)
|
| 59 |
+
except _jsonschema.ValidationError as exc:
|
| 60 |
+
raise ValueError(f"Schema validation failed: {exc.message}") from exc
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def compute_schema_hash(descriptor_partial: dict) -> str:
|
| 64 |
+
"""SHA-256 (or BLAKE3 if available) over canonical-JSON of descriptor."""
|
| 65 |
+
canonical = json.dumps(
|
| 66 |
+
descriptor_partial, sort_keys=True, separators=(",", ":")
|
| 67 |
+
).encode()
|
| 68 |
+
try:
|
| 69 |
+
import blake3 # type: ignore[import]
|
| 70 |
+
|
| 71 |
+
return "blake3:" + blake3.blake3(canonical).hexdigest()
|
| 72 |
+
except ImportError:
|
| 73 |
+
return "sha256:" + hashlib.sha256(canonical).hexdigest()
|
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Bus trace events for call tracking."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@dataclass(frozen=True)
|
| 9 |
+
class CallTraceEvent:
|
| 10 |
+
ts: str # ISO timestamp
|
| 11 |
+
trace_id: str
|
| 12 |
+
capability: str
|
| 13 |
+
version: str
|
| 14 |
+
caller: str
|
| 15 |
+
routed_to: str # node_id
|
| 16 |
+
is_local: bool
|
| 17 |
+
success: bool
|
| 18 |
+
error_code: str | None
|
| 19 |
+
latency_ms: int
|
| 20 |
+
bytes_in: int
|
| 21 |
+
bytes_out: int
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class TraceHook:
|
| 25 |
+
"""Emits trace events to the ring buffer (X03) and Prometheus metrics."""
|
| 26 |
+
|
| 27 |
+
def __init__(self, ring_buffer: Any = None, metrics: Any = None) -> None:
|
| 28 |
+
self._ring = ring_buffer
|
| 29 |
+
self._metrics = metrics
|
| 30 |
+
|
| 31 |
+
def record(self, event: CallTraceEvent) -> None:
|
| 32 |
+
if self._ring is not None:
|
| 33 |
+
try:
|
| 34 |
+
self._ring.push(event)
|
| 35 |
+
except Exception:
|
| 36 |
+
pass
|
|
@@ -0,0 +1,393 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HearthNet CLI — `hearthnet` command."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import asyncio
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import urllib.parse
|
| 9 |
+
import zipfile
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import click
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# HTTP helpers
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
|
| 18 |
+
_ALLOWED_SCHEMES = {"http", "https"}
|
| 19 |
+
_ALLOWED_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _validate_local_url(url: str) -> None:
|
| 23 |
+
"""Raise ValueError if the URL is not a local node URL (security boundary)."""
|
| 24 |
+
parsed = urllib.parse.urlparse(url)
|
| 25 |
+
if parsed.scheme not in _ALLOWED_SCHEMES:
|
| 26 |
+
raise ValueError(f"URL scheme must be http/https, got: {parsed.scheme!r}")
|
| 27 |
+
host = parsed.hostname or ""
|
| 28 |
+
if host not in _ALLOWED_HOSTS:
|
| 29 |
+
raise ValueError(
|
| 30 |
+
f"CLI only connects to local node. Got host: {host!r}. "
|
| 31 |
+
"Use --base-url http://localhost:<port> to override."
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _http_get(url: str) -> dict:
|
| 36 |
+
_validate_local_url(url)
|
| 37 |
+
try:
|
| 38 |
+
import httpx
|
| 39 |
+
|
| 40 |
+
resp = httpx.get(url, timeout=5)
|
| 41 |
+
resp.raise_for_status()
|
| 42 |
+
return resp.json()
|
| 43 |
+
except ImportError:
|
| 44 |
+
import urllib.error
|
| 45 |
+
import urllib.request
|
| 46 |
+
|
| 47 |
+
try:
|
| 48 |
+
with urllib.request.urlopen(url, timeout=5) as r:
|
| 49 |
+
return json.loads(r.read().decode())
|
| 50 |
+
except urllib.error.URLError as exc:
|
| 51 |
+
raise ConnectionError(str(exc)) from exc
|
| 52 |
+
except Exception as exc:
|
| 53 |
+
msg = str(exc).lower()
|
| 54 |
+
if any(kw in msg for kw in ("connect", "refused", "unreachable", "network")):
|
| 55 |
+
raise ConnectionError(str(exc)) from exc
|
| 56 |
+
raise
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _http_post(url: str, body: str) -> dict:
|
| 60 |
+
_validate_local_url(url)
|
| 61 |
+
try:
|
| 62 |
+
import httpx
|
| 63 |
+
|
| 64 |
+
resp = httpx.post(url, content=body, headers={"Content-Type": "application/json"}, timeout=30)
|
| 65 |
+
resp.raise_for_status()
|
| 66 |
+
return resp.json()
|
| 67 |
+
except ImportError:
|
| 68 |
+
import urllib.error
|
| 69 |
+
import urllib.request
|
| 70 |
+
|
| 71 |
+
req = urllib.request.Request(
|
| 72 |
+
url,
|
| 73 |
+
data=body.encode(),
|
| 74 |
+
headers={"Content-Type": "application/json"},
|
| 75 |
+
method="POST",
|
| 76 |
+
)
|
| 77 |
+
try:
|
| 78 |
+
with urllib.request.urlopen(req, timeout=30) as r:
|
| 79 |
+
return json.loads(r.read().decode())
|
| 80 |
+
except urllib.error.URLError as exc:
|
| 81 |
+
raise ConnectionError(str(exc)) from exc
|
| 82 |
+
except Exception as exc:
|
| 83 |
+
msg = str(exc).lower()
|
| 84 |
+
if any(kw in msg for kw in ("connect", "refused", "unreachable", "network")):
|
| 85 |
+
raise ConnectionError(str(exc)) from exc
|
| 86 |
+
raise
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
# CLI group
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@click.group()
|
| 95 |
+
@click.version_option(version="0.1.0")
|
| 96 |
+
@click.option("--config", "config_path", type=click.Path(), default=None, help="Path to config.toml")
|
| 97 |
+
@click.pass_context
|
| 98 |
+
def main(ctx: click.Context, config_path: str | None) -> None:
|
| 99 |
+
"""HearthNet — community-owned local AI mesh."""
|
| 100 |
+
ctx.ensure_object(dict)
|
| 101 |
+
ctx.obj["config_path"] = Path(config_path) if config_path else None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
# init
|
| 106 |
+
# ---------------------------------------------------------------------------
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@main.command()
|
| 110 |
+
@click.option("--name", default=None, help="Display name for this node")
|
| 111 |
+
@click.option(
|
| 112 |
+
"--profile",
|
| 113 |
+
type=click.Choice(["anchor", "hearth", "spark"]),
|
| 114 |
+
default="hearth",
|
| 115 |
+
)
|
| 116 |
+
@click.option("--non-interactive", is_flag=True)
|
| 117 |
+
def init(name: str | None, profile: str, non_interactive: bool) -> None:
|
| 118 |
+
"""Bootstrap a new HearthNet node. Generates keypair, writes config."""
|
| 119 |
+
config_dir = Path.home() / ".hearthnet"
|
| 120 |
+
config_dir.mkdir(parents=True, exist_ok=True)
|
| 121 |
+
keys_dir = config_dir / "keys"
|
| 122 |
+
keys_dir.mkdir(parents=True, exist_ok=True)
|
| 123 |
+
|
| 124 |
+
if not name and not non_interactive:
|
| 125 |
+
name = click.prompt("Node display name", default=f"HearthNode-{os.urandom(2).hex()}")
|
| 126 |
+
elif not name:
|
| 127 |
+
name = f"HearthNode-{os.urandom(2).hex()}"
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
from hearthnet.identity import load_or_generate
|
| 131 |
+
|
| 132 |
+
kp = load_or_generate(keys_dir)
|
| 133 |
+
click.echo(f"Node ID : {kp.node_id_full}")
|
| 134 |
+
click.echo(f"Short ID : {kp.node_id_short}")
|
| 135 |
+
except Exception as exc:
|
| 136 |
+
click.echo(f"Warning: could not generate keypair ({exc}). Skipping.", err=True)
|
| 137 |
+
|
| 138 |
+
config_file = config_dir / "config.toml"
|
| 139 |
+
if not config_file.exists():
|
| 140 |
+
config_file.write_text(
|
| 141 |
+
f'[node]\nname = "{name}"\nprofile = "{profile}"\n\n[identity]\nkeys_dir = "{keys_dir}"\n'
|
| 142 |
+
)
|
| 143 |
+
click.echo(f"Config written to {config_file}")
|
| 144 |
+
else:
|
| 145 |
+
click.echo(f"Config already exists at {config_file} — not overwritten.")
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
# ---------------------------------------------------------------------------
|
| 149 |
+
# run
|
| 150 |
+
# ---------------------------------------------------------------------------
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
@main.command()
|
| 154 |
+
@click.option("--no-ui", is_flag=True, help="Run without Gradio UI")
|
| 155 |
+
@click.option("--debug", is_flag=True)
|
| 156 |
+
@click.pass_context
|
| 157 |
+
def run(ctx: click.Context, no_ui: bool, debug: bool) -> None:
|
| 158 |
+
"""Start the HearthNet node."""
|
| 159 |
+
if debug:
|
| 160 |
+
import logging
|
| 161 |
+
|
| 162 |
+
logging.basicConfig(level=logging.DEBUG)
|
| 163 |
+
|
| 164 |
+
click.echo("HearthNet node starting…")
|
| 165 |
+
|
| 166 |
+
if not no_ui:
|
| 167 |
+
try:
|
| 168 |
+
from app import demo # type: ignore[import]
|
| 169 |
+
|
| 170 |
+
demo.launch()
|
| 171 |
+
except Exception as exc:
|
| 172 |
+
click.echo(f"Could not start Gradio UI: {exc}", err=True)
|
| 173 |
+
click.echo("Try `hearthnet run --no-ui` to start without UI.")
|
| 174 |
+
sys.exit(1)
|
| 175 |
+
else:
|
| 176 |
+
click.echo("Running in headless mode. Press Ctrl+C to stop.")
|
| 177 |
+
try:
|
| 178 |
+
asyncio.run(_headless())
|
| 179 |
+
except KeyboardInterrupt:
|
| 180 |
+
click.echo("Shutting down.")
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
async def _headless() -> None:
|
| 184 |
+
while True:
|
| 185 |
+
await asyncio.sleep(3600)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
# ---------------------------------------------------------------------------
|
| 189 |
+
# status
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
@main.command()
|
| 194 |
+
@click.option("--json", "as_json", is_flag=True)
|
| 195 |
+
@click.option("--host", default="127.0.0.1")
|
| 196 |
+
@click.option("--port", default=7080, type=int)
|
| 197 |
+
@click.pass_context
|
| 198 |
+
def status(ctx: click.Context, as_json: bool, host: str, port: int) -> None:
|
| 199 |
+
"""Show node status (requires a running node)."""
|
| 200 |
+
url = f"http://{host}:{port}/health"
|
| 201 |
+
try:
|
| 202 |
+
data = _http_get(url)
|
| 203 |
+
except ConnectionError:
|
| 204 |
+
click.echo(f"Node not reachable at {host}:{port}")
|
| 205 |
+
sys.exit(3)
|
| 206 |
+
|
| 207 |
+
if as_json:
|
| 208 |
+
click.echo(json.dumps(data, indent=2))
|
| 209 |
+
else:
|
| 210 |
+
click.echo(f"Status : {data.get('status', 'unknown')}")
|
| 211 |
+
click.echo(f"Node ID : {data.get('node_id', 'N/A')}")
|
| 212 |
+
click.echo(f"Version : {data.get('version', 'N/A')}")
|
| 213 |
+
extras = {k: v for k, v in data.items() if k not in ("status", "node_id", "version")}
|
| 214 |
+
for k, v in extras.items():
|
| 215 |
+
click.echo(f"{k:<10}: {v}")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
# ---------------------------------------------------------------------------
|
| 219 |
+
# caps
|
| 220 |
+
# ---------------------------------------------------------------------------
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@main.command()
|
| 224 |
+
@click.option("--remote-only", is_flag=True)
|
| 225 |
+
@click.option("--local-only", is_flag=True)
|
| 226 |
+
@click.option("--name", "name_pattern", default=None)
|
| 227 |
+
@click.option("--host", default="127.0.0.1")
|
| 228 |
+
@click.option("--port", default=7080, type=int)
|
| 229 |
+
def caps(
|
| 230 |
+
remote_only: bool,
|
| 231 |
+
local_only: bool,
|
| 232 |
+
name_pattern: str | None,
|
| 233 |
+
host: str,
|
| 234 |
+
port: int,
|
| 235 |
+
) -> None:
|
| 236 |
+
"""List capability entries."""
|
| 237 |
+
url = f"http://{host}:{port}/bus/v1/capabilities"
|
| 238 |
+
try:
|
| 239 |
+
data = _http_get(url)
|
| 240 |
+
except ConnectionError:
|
| 241 |
+
click.echo(f"Node not reachable at {host}:{port}")
|
| 242 |
+
sys.exit(3)
|
| 243 |
+
|
| 244 |
+
entries = data if isinstance(data, list) else data.get("capabilities", [])
|
| 245 |
+
|
| 246 |
+
if remote_only:
|
| 247 |
+
entries = [e for e in entries if not e.get("local", False)]
|
| 248 |
+
elif local_only:
|
| 249 |
+
entries = [e for e in entries if e.get("local", False)]
|
| 250 |
+
|
| 251 |
+
if name_pattern:
|
| 252 |
+
entries = [e for e in entries if name_pattern.lower() in e.get("name", "").lower()]
|
| 253 |
+
|
| 254 |
+
if not entries:
|
| 255 |
+
click.echo("No capabilities found.")
|
| 256 |
+
return
|
| 257 |
+
|
| 258 |
+
click.echo(f"{'NAME':<30} {'VERSION':<10} {'STABILITY':<12} {'LOCAL'}")
|
| 259 |
+
click.echo("-" * 60)
|
| 260 |
+
for entry in entries:
|
| 261 |
+
click.echo(
|
| 262 |
+
f"{entry.get('name', '?'):<30} "
|
| 263 |
+
f"{entry.get('version', '?'):<10} "
|
| 264 |
+
f"{entry.get('stability', '?'):<12} "
|
| 265 |
+
f"{'yes' if entry.get('local') else 'no'}"
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
# ---------------------------------------------------------------------------
|
| 270 |
+
# call
|
| 271 |
+
# ---------------------------------------------------------------------------
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
@main.command()
|
| 275 |
+
@click.argument("capability")
|
| 276 |
+
@click.option("--body", default="{}", help="JSON body")
|
| 277 |
+
@click.option("--stream", is_flag=True)
|
| 278 |
+
@click.option("--host", default="127.0.0.1")
|
| 279 |
+
@click.option("--port", default=7080, type=int)
|
| 280 |
+
def call(capability: str, body: str, stream: bool, host: str, port: int) -> None:
|
| 281 |
+
"""Make a one-shot capability call."""
|
| 282 |
+
# Validate body is valid JSON before sending
|
| 283 |
+
try:
|
| 284 |
+
json.loads(body)
|
| 285 |
+
except json.JSONDecodeError as exc:
|
| 286 |
+
click.echo(f"Invalid JSON body: {exc}", err=True)
|
| 287 |
+
sys.exit(1)
|
| 288 |
+
|
| 289 |
+
url = f"http://{host}:{port}/bus/v1/call"
|
| 290 |
+
payload = json.dumps({"capability": capability, "body": json.loads(body)})
|
| 291 |
+
try:
|
| 292 |
+
result = _http_post(url, payload)
|
| 293 |
+
except ConnectionError:
|
| 294 |
+
click.echo(f"Node not reachable at {host}:{port}")
|
| 295 |
+
sys.exit(3)
|
| 296 |
+
|
| 297 |
+
click.echo(json.dumps(result, indent=2))
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# ---------------------------------------------------------------------------
|
| 301 |
+
# doctor
|
| 302 |
+
# ---------------------------------------------------------------------------
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
@main.command()
|
| 306 |
+
@click.option("--check", default=None, help="Run specific check by name")
|
| 307 |
+
def doctor(check: str | None) -> None:
|
| 308 |
+
"""Run self-diagnostics."""
|
| 309 |
+
try:
|
| 310 |
+
from hearthnet.observability.doctor import run_all, run_one
|
| 311 |
+
|
| 312 |
+
if check:
|
| 313 |
+
results = [run_one(check)]
|
| 314 |
+
else:
|
| 315 |
+
results = run_all()
|
| 316 |
+
all_passed = all(r.passed for r in results)
|
| 317 |
+
for r in results:
|
| 318 |
+
icon = "✔" if r.passed else "✘"
|
| 319 |
+
click.echo(f" {icon} {r.check.name:<25} {r.message}")
|
| 320 |
+
if not r.passed and r.check.fix_hint:
|
| 321 |
+
click.echo(f" → fix: {r.check.fix_hint}")
|
| 322 |
+
sys.exit(0 if all_passed else 1)
|
| 323 |
+
except Exception as exc:
|
| 324 |
+
click.echo(f"doctor crashed: {exc}", err=True)
|
| 325 |
+
sys.exit(2)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
# ---------------------------------------------------------------------------
|
| 329 |
+
# trace
|
| 330 |
+
# ---------------------------------------------------------------------------
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
@main.command()
|
| 334 |
+
@click.argument("n", default=20, type=int)
|
| 335 |
+
@click.option("--capability", default=None)
|
| 336 |
+
@click.option("--host", default="127.0.0.1")
|
| 337 |
+
@click.option("--port", default=7080, type=int)
|
| 338 |
+
def trace(n: int, capability: str | None, host: str, port: int) -> None:
|
| 339 |
+
"""Show recent call traces."""
|
| 340 |
+
url = f"http://{host}:{port}/trace/recent?n={n}"
|
| 341 |
+
if capability:
|
| 342 |
+
url += f"&capability={capability}"
|
| 343 |
+
try:
|
| 344 |
+
data = _http_get(url)
|
| 345 |
+
except ConnectionError:
|
| 346 |
+
click.echo(f"Node not reachable at {host}:{port}")
|
| 347 |
+
sys.exit(3)
|
| 348 |
+
|
| 349 |
+
entries = data if isinstance(data, list) else data.get("traces", [])
|
| 350 |
+
if not entries:
|
| 351 |
+
click.echo("No traces found.")
|
| 352 |
+
return
|
| 353 |
+
|
| 354 |
+
for entry in entries:
|
| 355 |
+
ts = entry.get("ts", "?")
|
| 356 |
+
cap = entry.get("capability", "?")
|
| 357 |
+
dur = entry.get("duration_ms", "?")
|
| 358 |
+
ok = "OK" if entry.get("success", True) else "ERR"
|
| 359 |
+
click.echo(f" [{ts}] {cap:<30} {dur:>6}ms {ok}")
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
# ---------------------------------------------------------------------------
|
| 363 |
+
# export
|
| 364 |
+
# ---------------------------------------------------------------------------
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
@main.command()
|
| 368 |
+
@click.option("--out", type=click.Path(), default=None)
|
| 369 |
+
def export(out: str | None) -> None:
|
| 370 |
+
"""Export all local data (GDPR right-to-export)."""
|
| 371 |
+
config_dir = Path.home() / ".hearthnet"
|
| 372 |
+
out_path = Path(out) if out else Path.cwd() / "hearthnet-export.zip"
|
| 373 |
+
|
| 374 |
+
try:
|
| 375 |
+
with zipfile.ZipFile(out_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
| 376 |
+
if config_dir.exists():
|
| 377 |
+
for item in config_dir.rglob("*"):
|
| 378 |
+
# Skip private key material
|
| 379 |
+
if item.suffix in (".key", ".pem") or item.name.startswith("signing"):
|
| 380 |
+
continue
|
| 381 |
+
if item.is_file():
|
| 382 |
+
zf.write(item, item.relative_to(config_dir.parent))
|
| 383 |
+
# Add a manifest of what was exported
|
| 384 |
+
manifest = {
|
| 385 |
+
"export_version": 1,
|
| 386 |
+
"exported_from": str(config_dir),
|
| 387 |
+
"contains": "node config, identity (public parts only)",
|
| 388 |
+
}
|
| 389 |
+
zf.writestr("EXPORT_MANIFEST.json", json.dumps(manifest, indent=2))
|
| 390 |
+
click.echo(f"Exported to {out_path}")
|
| 391 |
+
except Exception as exc:
|
| 392 |
+
click.echo(f"Export failed: {exc}", err=True)
|
| 393 |
+
sys.exit(1)
|
|
@@ -0,0 +1,498 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HearthNet — X04 Configuration.
|
| 2 |
+
|
| 3 |
+
Typed, frozen config loaded from TOML. No module reads env-vars or files
|
| 4 |
+
directly — they all use a Config instance handed to them.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import tomllib # stdlib ≥ 3.11; fallback below
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
from hearthnet.constants import (
|
| 15 |
+
CHUNK_SIZE_BYTES,
|
| 16 |
+
EMBED_DEFAULT_MODEL,
|
| 17 |
+
HTTP_PORT,
|
| 18 |
+
MARKET_DEFAULT_TTL_SECONDS,
|
| 19 |
+
MARKET_MAX_TTL_SECONDS,
|
| 20 |
+
UI_PORT,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
# ── Fall back to tomli for Python < 3.11 ────────────────────────────────────
|
| 24 |
+
try:
|
| 25 |
+
import tomllib
|
| 26 |
+
except ImportError:
|
| 27 |
+
try:
|
| 28 |
+
import tomli as tomllib # type: ignore[no-redef]
|
| 29 |
+
except ImportError:
|
| 30 |
+
tomllib = None # type: ignore[assignment]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── Sub-config dataclasses ───────────────────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
@dataclass(frozen=True)
|
| 36 |
+
class IdentityConfig:
|
| 37 |
+
keys_dir: Path = field(default_factory=lambda: Path())
|
| 38 |
+
auto_generate: bool = True
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass(frozen=True)
|
| 42 |
+
class CommunityConfig:
|
| 43 |
+
community_id: str | None = None
|
| 44 |
+
state_dir: Path = field(default_factory=lambda: Path())
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass(frozen=True)
|
| 48 |
+
class TransportConfig:
|
| 49 |
+
host: str = "0.0.0.0"
|
| 50 |
+
port: int = HTTP_PORT
|
| 51 |
+
tls_cert: Path | None = None
|
| 52 |
+
tls_key: Path | None = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass(frozen=True)
|
| 56 |
+
class DiscoveryConfig:
|
| 57 |
+
mdns_enabled: bool = True
|
| 58 |
+
udp_enabled: bool = True
|
| 59 |
+
udp_port: int = 7079
|
| 60 |
+
relay_urls: tuple[str, ...] = field(default_factory=tuple)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@dataclass(frozen=True)
|
| 64 |
+
class BusConfig:
|
| 65 |
+
prefer_local: bool = True
|
| 66 |
+
local_load_threshold: float = 0.80
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@dataclass(frozen=True)
|
| 70 |
+
class LlmBackendConfig:
|
| 71 |
+
name: str
|
| 72 |
+
model: str = ""
|
| 73 |
+
base_url: str = ""
|
| 74 |
+
api_key_env: str | None = None
|
| 75 |
+
extra: dict = field(default_factory=dict)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@dataclass(frozen=True)
|
| 79 |
+
class LlmConfig:
|
| 80 |
+
backends: tuple[LlmBackendConfig, ...] = field(default_factory=tuple)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
@dataclass(frozen=True)
|
| 84 |
+
class EmbeddingConfig:
|
| 85 |
+
model: str = EMBED_DEFAULT_MODEL
|
| 86 |
+
device: str = "auto"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@dataclass(frozen=True)
|
| 90 |
+
class RagConfig:
|
| 91 |
+
enabled: bool = True
|
| 92 |
+
corpora_dir: Path = field(default_factory=lambda: Path())
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@dataclass(frozen=True)
|
| 96 |
+
class FileConfig:
|
| 97 |
+
blobs_dir: Path = field(default_factory=lambda: Path())
|
| 98 |
+
chunk_size_bytes: int = CHUNK_SIZE_BYTES
|
| 99 |
+
gc_threshold: float = 0.80
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@dataclass(frozen=True)
|
| 103 |
+
class MarketConfig:
|
| 104 |
+
enabled: bool = True
|
| 105 |
+
default_ttl_seconds: int = MARKET_DEFAULT_TTL_SECONDS
|
| 106 |
+
max_ttl_seconds: int = MARKET_MAX_TTL_SECONDS
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
@dataclass(frozen=True)
|
| 110 |
+
class ChatConfig:
|
| 111 |
+
enabled: bool = True
|
| 112 |
+
store_and_forward: bool = True
|
| 113 |
+
read_receipts_enabled: bool = True
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass(frozen=True)
|
| 117 |
+
class EmergencyConfig:
|
| 118 |
+
probe_targets: tuple[str, ...] = field(
|
| 119 |
+
default_factory=lambda: (
|
| 120 |
+
"1.1.1.1",
|
| 121 |
+
"8.8.8.8",
|
| 122 |
+
"https://cloudflare.com",
|
| 123 |
+
"https://quad9.net",
|
| 124 |
+
)
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@dataclass(frozen=True)
|
| 129 |
+
class UiConfig:
|
| 130 |
+
host: str = "127.0.0.1"
|
| 131 |
+
port: int = UI_PORT
|
| 132 |
+
launch_browser: bool = True
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@dataclass(frozen=True)
|
| 136 |
+
class ObservabilityConfig:
|
| 137 |
+
log_level: str = "info"
|
| 138 |
+
log_dir: Path | None = None
|
| 139 |
+
metrics_enabled: bool = True
|
| 140 |
+
otlp_endpoint: str | None = None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@dataclass(frozen=True)
|
| 144 |
+
class Config:
|
| 145 |
+
identity: IdentityConfig = field(default_factory=IdentityConfig)
|
| 146 |
+
community: CommunityConfig = field(default_factory=CommunityConfig)
|
| 147 |
+
transport: TransportConfig = field(default_factory=TransportConfig)
|
| 148 |
+
discovery: DiscoveryConfig = field(default_factory=DiscoveryConfig)
|
| 149 |
+
bus: BusConfig = field(default_factory=BusConfig)
|
| 150 |
+
llm: LlmConfig = field(default_factory=LlmConfig)
|
| 151 |
+
embedding: EmbeddingConfig = field(default_factory=EmbeddingConfig)
|
| 152 |
+
rag: RagConfig = field(default_factory=RagConfig)
|
| 153 |
+
file: FileConfig = field(default_factory=FileConfig)
|
| 154 |
+
market: MarketConfig = field(default_factory=MarketConfig)
|
| 155 |
+
chat: ChatConfig = field(default_factory=ChatConfig)
|
| 156 |
+
emergency: EmergencyConfig = field(default_factory=EmergencyConfig)
|
| 157 |
+
ui: UiConfig = field(default_factory=UiConfig)
|
| 158 |
+
observability: ObservabilityConfig = field(default_factory=ObservabilityConfig)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# ── ConfigError ───────────────────────────────────────────────────────────────
|
| 162 |
+
|
| 163 |
+
class ConfigError(Exception):
|
| 164 |
+
def __init__(self, code: str, **kwargs: object) -> None:
|
| 165 |
+
super().__init__(code)
|
| 166 |
+
self.code = code
|
| 167 |
+
self.context = kwargs
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ── XDG path resolution ──────────────────────────────────────────��────────────
|
| 171 |
+
|
| 172 |
+
def _xdg_data() -> Path:
|
| 173 |
+
raw = os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share")
|
| 174 |
+
return Path(raw) / "hearthnet"
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _xdg_config() -> Path:
|
| 178 |
+
raw = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
| 179 |
+
return Path(raw) / "hearthnet"
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _xdg_cache() -> Path:
|
| 183 |
+
raw = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
|
| 184 |
+
return Path(raw) / "hearthnet"
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _default_config_path() -> Path:
|
| 188 |
+
return _xdg_config() / "config.toml"
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ── Path resolution ───────────────────────────────────────────────────────────
|
| 192 |
+
|
| 193 |
+
def resolve_paths(config: Config) -> Config:
|
| 194 |
+
"""Fill empty Path() fields with XDG-standard locations. Idempotent."""
|
| 195 |
+
data = _xdg_data()
|
| 196 |
+
cache = _xdg_cache()
|
| 197 |
+
cfg = _xdg_config()
|
| 198 |
+
|
| 199 |
+
identity = config.identity
|
| 200 |
+
if identity.keys_dir == Path():
|
| 201 |
+
identity = IdentityConfig(
|
| 202 |
+
keys_dir=data / "keys",
|
| 203 |
+
auto_generate=identity.auto_generate,
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
community = config.community
|
| 207 |
+
if community.state_dir == Path():
|
| 208 |
+
cid = community.community_id or "default"
|
| 209 |
+
community = CommunityConfig(
|
| 210 |
+
community_id=community.community_id,
|
| 211 |
+
state_dir=data / "communities" / cid,
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
transport = config.transport
|
| 215 |
+
tls_cert = transport.tls_cert or data / "tls" / "server.crt"
|
| 216 |
+
tls_key = transport.tls_key or data / "tls" / "server.key"
|
| 217 |
+
transport = TransportConfig(
|
| 218 |
+
host=transport.host,
|
| 219 |
+
port=transport.port,
|
| 220 |
+
tls_cert=tls_cert,
|
| 221 |
+
tls_key=tls_key,
|
| 222 |
+
)
|
| 223 |
+
|
| 224 |
+
rag = config.rag
|
| 225 |
+
if rag.corpora_dir == Path():
|
| 226 |
+
rag = RagConfig(enabled=rag.enabled, corpora_dir=cache / "embeddings")
|
| 227 |
+
|
| 228 |
+
file_cfg = config.file
|
| 229 |
+
if file_cfg.blobs_dir == Path():
|
| 230 |
+
file_cfg = FileConfig(
|
| 231 |
+
blobs_dir=data / "blobs",
|
| 232 |
+
chunk_size_bytes=file_cfg.chunk_size_bytes,
|
| 233 |
+
gc_threshold=file_cfg.gc_threshold,
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
obs = config.observability
|
| 237 |
+
if obs.log_dir is None:
|
| 238 |
+
obs = ObservabilityConfig(
|
| 239 |
+
log_level=obs.log_level,
|
| 240 |
+
log_dir=data / "logs",
|
| 241 |
+
metrics_enabled=obs.metrics_enabled,
|
| 242 |
+
otlp_endpoint=obs.otlp_endpoint,
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
return Config(
|
| 246 |
+
identity=identity,
|
| 247 |
+
community=community,
|
| 248 |
+
transport=transport,
|
| 249 |
+
discovery=config.discovery,
|
| 250 |
+
bus=config.bus,
|
| 251 |
+
llm=config.llm,
|
| 252 |
+
embedding=config.embedding,
|
| 253 |
+
rag=rag,
|
| 254 |
+
file=file_cfg,
|
| 255 |
+
market=config.market,
|
| 256 |
+
chat=config.chat,
|
| 257 |
+
emergency=config.emergency,
|
| 258 |
+
ui=config.ui,
|
| 259 |
+
observability=obs,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
# ── Validation ────────────────────────────────────────────────────────────────
|
| 264 |
+
|
| 265 |
+
def validate(config: Config) -> None:
|
| 266 |
+
"""Cross-field validation. Raises ConfigError on failure."""
|
| 267 |
+
t = config.transport
|
| 268 |
+
d = config.discovery
|
| 269 |
+
if t.port == d.udp_port:
|
| 270 |
+
raise ConfigError("invalid_field", field="transport.port/discovery.udp_port",
|
| 271 |
+
reason="transport port and UDP discovery port must differ")
|
| 272 |
+
if not (1 <= t.port <= 65535):
|
| 273 |
+
raise ConfigError("invalid_field", field="transport.port", reason="port out of range")
|
| 274 |
+
if config.bus.local_load_threshold <= 0 or config.bus.local_load_threshold > 1:
|
| 275 |
+
raise ConfigError("invalid_field", field="bus.local_load_threshold",
|
| 276 |
+
reason="must be in (0, 1]")
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
# ── TOML parsing helpers ──────────────────────────────────────────────────────
|
| 280 |
+
|
| 281 |
+
def _parse_toml(text: str) -> dict:
|
| 282 |
+
if tomllib is None:
|
| 283 |
+
raise ConfigError("invalid_toml", reason="no TOML parser available (install tomli)")
|
| 284 |
+
try:
|
| 285 |
+
return tomllib.loads(text)
|
| 286 |
+
except Exception as exc:
|
| 287 |
+
raise ConfigError("invalid_toml", reason=str(exc)) from exc
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _from_dict(raw: dict) -> Config:
|
| 291 |
+
def _path(v: object) -> Path:
|
| 292 |
+
return Path(v) if v else Path()
|
| 293 |
+
|
| 294 |
+
identity_raw = raw.get("identity", {})
|
| 295 |
+
identity = IdentityConfig(
|
| 296 |
+
keys_dir=_path(identity_raw.get("keys_dir")),
|
| 297 |
+
auto_generate=bool(identity_raw.get("auto_generate", True)),
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
community_raw = raw.get("community", {})
|
| 301 |
+
community = CommunityConfig(
|
| 302 |
+
community_id=community_raw.get("community_id") or None,
|
| 303 |
+
state_dir=_path(community_raw.get("state_dir")),
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
transport_raw = raw.get("transport", {})
|
| 307 |
+
transport = TransportConfig(
|
| 308 |
+
host=str(transport_raw.get("host", "0.0.0.0")),
|
| 309 |
+
port=int(transport_raw.get("port", HTTP_PORT)),
|
| 310 |
+
tls_cert=_path(transport_raw.get("tls_cert")) or None,
|
| 311 |
+
tls_key=_path(transport_raw.get("tls_key")) or None,
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
discovery_raw = raw.get("discovery", {})
|
| 315 |
+
discovery = DiscoveryConfig(
|
| 316 |
+
mdns_enabled=bool(discovery_raw.get("mdns_enabled", True)),
|
| 317 |
+
udp_enabled=bool(discovery_raw.get("udp_enabled", True)),
|
| 318 |
+
udp_port=int(discovery_raw.get("udp_port", 7079)),
|
| 319 |
+
relay_urls=tuple(discovery_raw.get("relay_urls", [])),
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
bus_raw = raw.get("bus", {})
|
| 323 |
+
bus = BusConfig(
|
| 324 |
+
prefer_local=bool(bus_raw.get("prefer_local", True)),
|
| 325 |
+
local_load_threshold=float(bus_raw.get("local_load_threshold", 0.80)),
|
| 326 |
+
)
|
| 327 |
+
|
| 328 |
+
llm_raw = raw.get("llm", {})
|
| 329 |
+
backends = []
|
| 330 |
+
for b in llm_raw.get("backends", []):
|
| 331 |
+
backends.append(LlmBackendConfig(
|
| 332 |
+
name=str(b["name"]),
|
| 333 |
+
model=str(b.get("model", "")),
|
| 334 |
+
base_url=str(b.get("base_url", "")),
|
| 335 |
+
api_key_env=b.get("api_key_env") or None,
|
| 336 |
+
))
|
| 337 |
+
llm = LlmConfig(backends=tuple(backends))
|
| 338 |
+
|
| 339 |
+
embedding_raw = raw.get("embedding", {})
|
| 340 |
+
embedding = EmbeddingConfig(
|
| 341 |
+
model=str(embedding_raw.get("model", EMBED_DEFAULT_MODEL)),
|
| 342 |
+
device=str(embedding_raw.get("device", "auto")),
|
| 343 |
+
)
|
| 344 |
+
|
| 345 |
+
rag_raw = raw.get("rag", {})
|
| 346 |
+
rag = RagConfig(
|
| 347 |
+
enabled=bool(rag_raw.get("enabled", True)),
|
| 348 |
+
corpora_dir=_path(rag_raw.get("corpora_dir")),
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
file_raw = raw.get("file", {})
|
| 352 |
+
file_cfg = FileConfig(
|
| 353 |
+
blobs_dir=_path(file_raw.get("blobs_dir")),
|
| 354 |
+
chunk_size_bytes=int(file_raw.get("chunk_size_bytes", CHUNK_SIZE_BYTES)),
|
| 355 |
+
gc_threshold=float(file_raw.get("gc_threshold", 0.80)),
|
| 356 |
+
)
|
| 357 |
+
|
| 358 |
+
market_raw = raw.get("market", {})
|
| 359 |
+
market = MarketConfig(
|
| 360 |
+
enabled=bool(market_raw.get("enabled", True)),
|
| 361 |
+
default_ttl_seconds=int(market_raw.get("default_ttl_seconds", MARKET_DEFAULT_TTL_SECONDS)),
|
| 362 |
+
max_ttl_seconds=int(market_raw.get("max_ttl_seconds", MARKET_MAX_TTL_SECONDS)),
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
chat_raw = raw.get("chat", {})
|
| 366 |
+
chat = ChatConfig(
|
| 367 |
+
enabled=bool(chat_raw.get("enabled", True)),
|
| 368 |
+
store_and_forward=bool(chat_raw.get("store_and_forward", True)),
|
| 369 |
+
read_receipts_enabled=bool(chat_raw.get("read_receipts_enabled", True)),
|
| 370 |
+
)
|
| 371 |
+
|
| 372 |
+
emergency_raw = raw.get("emergency", {})
|
| 373 |
+
emergency = EmergencyConfig(
|
| 374 |
+
probe_targets=tuple(emergency_raw.get("probe_targets", [
|
| 375 |
+
"1.1.1.1", "8.8.8.8", "https://cloudflare.com", "https://quad9.net",
|
| 376 |
+
])),
|
| 377 |
+
)
|
| 378 |
+
|
| 379 |
+
ui_raw = raw.get("ui", {})
|
| 380 |
+
ui = UiConfig(
|
| 381 |
+
host=str(ui_raw.get("host", "127.0.0.1")),
|
| 382 |
+
port=int(ui_raw.get("port", UI_PORT)),
|
| 383 |
+
launch_browser=bool(ui_raw.get("launch_browser", True)),
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
obs_raw = raw.get("observability", {})
|
| 387 |
+
obs = ObservabilityConfig(
|
| 388 |
+
log_level=str(obs_raw.get("log_level", "info")),
|
| 389 |
+
log_dir=_path(obs_raw.get("log_dir")) or None,
|
| 390 |
+
metrics_enabled=bool(obs_raw.get("metrics_enabled", True)),
|
| 391 |
+
otlp_endpoint=obs_raw.get("otlp_endpoint") or None,
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
return Config(
|
| 395 |
+
identity=identity,
|
| 396 |
+
community=community,
|
| 397 |
+
transport=transport,
|
| 398 |
+
discovery=discovery,
|
| 399 |
+
bus=bus,
|
| 400 |
+
llm=llm,
|
| 401 |
+
embedding=embedding,
|
| 402 |
+
rag=rag,
|
| 403 |
+
file=file_cfg,
|
| 404 |
+
market=market,
|
| 405 |
+
chat=chat,
|
| 406 |
+
emergency=emergency,
|
| 407 |
+
ui=ui,
|
| 408 |
+
observability=obs,
|
| 409 |
+
)
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
# ── Public API ────────────────────────────────────────────────────────────────
|
| 413 |
+
|
| 414 |
+
def default_config() -> Config:
|
| 415 |
+
"""Return a Config populated entirely from defaults."""
|
| 416 |
+
return resolve_paths(Config())
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def load(path: Path | None = None) -> Config:
|
| 420 |
+
"""Load from TOML file; apply defaults for omitted sections; validate."""
|
| 421 |
+
cfg_path = path or _default_config_path()
|
| 422 |
+
if not cfg_path.exists():
|
| 423 |
+
cfg = default_config()
|
| 424 |
+
validate(cfg)
|
| 425 |
+
return cfg
|
| 426 |
+
try:
|
| 427 |
+
text = cfg_path.read_text(encoding="utf-8")
|
| 428 |
+
except OSError as exc:
|
| 429 |
+
raise ConfigError("path_resolution", reason=str(exc)) from exc
|
| 430 |
+
raw = _parse_toml(text)
|
| 431 |
+
cfg = resolve_paths(_from_dict(raw))
|
| 432 |
+
validate(cfg)
|
| 433 |
+
return cfg
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def save(config: Config, path: Path | None = None) -> None:
|
| 437 |
+
"""Serialise config to TOML atomically."""
|
| 438 |
+
import tempfile
|
| 439 |
+
|
| 440 |
+
cfg_path = path or _default_config_path()
|
| 441 |
+
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
| 442 |
+
|
| 443 |
+
lines: list[str] = []
|
| 444 |
+
lines.append("[identity]")
|
| 445 |
+
lines.append(f'keys_dir = "{config.identity.keys_dir}"')
|
| 446 |
+
lines.append(f"auto_generate = {str(config.identity.auto_generate).lower()}")
|
| 447 |
+
lines.append("")
|
| 448 |
+
lines.append("[community]")
|
| 449 |
+
if config.community.community_id:
|
| 450 |
+
lines.append(f'community_id = "{config.community.community_id}"')
|
| 451 |
+
lines.append(f'state_dir = "{config.community.state_dir}"')
|
| 452 |
+
lines.append("")
|
| 453 |
+
lines.append("[transport]")
|
| 454 |
+
lines.append(f'host = "{config.transport.host}"')
|
| 455 |
+
lines.append(f"port = {config.transport.port}")
|
| 456 |
+
if config.transport.tls_cert:
|
| 457 |
+
lines.append(f'tls_cert = "{config.transport.tls_cert}"')
|
| 458 |
+
if config.transport.tls_key:
|
| 459 |
+
lines.append(f'tls_key = "{config.transport.tls_key}"')
|
| 460 |
+
lines.append("")
|
| 461 |
+
lines.append("[discovery]")
|
| 462 |
+
lines.append(f"mdns_enabled = {str(config.discovery.mdns_enabled).lower()}")
|
| 463 |
+
lines.append(f"udp_enabled = {str(config.discovery.udp_enabled).lower()}")
|
| 464 |
+
lines.append(f"udp_port = {config.discovery.udp_port}")
|
| 465 |
+
if config.discovery.relay_urls:
|
| 466 |
+
urls = ", ".join(f'"{u}"' for u in config.discovery.relay_urls)
|
| 467 |
+
lines.append(f"relay_urls = [{urls}]")
|
| 468 |
+
lines.append("")
|
| 469 |
+
lines.append("[bus]")
|
| 470 |
+
lines.append(f"prefer_local = {str(config.bus.prefer_local).lower()}")
|
| 471 |
+
lines.append(f"local_load_threshold = {config.bus.local_load_threshold}")
|
| 472 |
+
lines.append("")
|
| 473 |
+
lines.append("[embedding]")
|
| 474 |
+
lines.append(f'model = "{config.embedding.model}"')
|
| 475 |
+
lines.append(f'device = "{config.embedding.device}"')
|
| 476 |
+
lines.append("")
|
| 477 |
+
lines.append("[rag]")
|
| 478 |
+
lines.append(f"enabled = {str(config.rag.enabled).lower()}")
|
| 479 |
+
lines.append(f'corpora_dir = "{config.rag.corpora_dir}"')
|
| 480 |
+
lines.append("")
|
| 481 |
+
lines.append("[observability]")
|
| 482 |
+
lines.append(f'log_level = "{config.observability.log_level}"')
|
| 483 |
+
lines.append(f"metrics_enabled = {str(config.observability.metrics_enabled).lower()}")
|
| 484 |
+
if config.observability.log_dir:
|
| 485 |
+
lines.append(f'log_dir = "{config.observability.log_dir}"')
|
| 486 |
+
|
| 487 |
+
content = "\n".join(lines) + "\n"
|
| 488 |
+
fd, tmp = tempfile.mkstemp(dir=cfg_path.parent, prefix=".config_tmp")
|
| 489 |
+
try:
|
| 490 |
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
| 491 |
+
fh.write(content)
|
| 492 |
+
os.replace(tmp, cfg_path)
|
| 493 |
+
except Exception:
|
| 494 |
+
try:
|
| 495 |
+
os.unlink(tmp)
|
| 496 |
+
except OSError:
|
| 497 |
+
pass
|
| 498 |
+
raise
|
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HearthNet — compile-time constants (numeric defaults, limits).
|
| 2 |
+
|
| 3 |
+
All module code that needs a tunable default imports from here.
|
| 4 |
+
Never hardcode these values inline.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
# ── Node manifest ────────────────────────────────────────────────────────────
|
| 9 |
+
MANIFEST_TTL_SECONDS: int = 30
|
| 10 |
+
MANIFEST_REFRESH_BEFORE_EXPIRY_SECONDS: int = 10
|
| 11 |
+
|
| 12 |
+
# ── Discovery ────────────────────────────────────────────────────────────────
|
| 13 |
+
MDNS_SERVICE_TYPE: str = "_hearthnet._tcp.local."
|
| 14 |
+
UDP_MULTICAST_GROUP: str = "224.0.0.251"
|
| 15 |
+
UDP_MULTICAST_PORT: int = 7079
|
| 16 |
+
UDP_ANNOUNCE_INTERVAL_ONLINE_SECONDS: int = 15
|
| 17 |
+
UDP_ANNOUNCE_INTERVAL_OFFLINE_SECONDS: int = 5
|
| 18 |
+
PEER_PRUNE_NORMAL_SECONDS: int = 90
|
| 19 |
+
PEER_PRUNE_AGGRESSIVE_SECONDS: int = 30
|
| 20 |
+
PEER_REFRESH_INTERVAL_SECONDS: int = 30
|
| 21 |
+
|
| 22 |
+
# ── Transport ────────────────────────────────────────────────────────────────
|
| 23 |
+
HTTP_PORT: int = 7080
|
| 24 |
+
UI_PORT: int = 7860
|
| 25 |
+
CONNECTION_IDLE_SECONDS: int = 60
|
| 26 |
+
RECONNECT_BACKOFF_CAP_SECONDS: int = 30
|
| 27 |
+
RATE_LIMIT_WINDOW_SECONDS: int = 60
|
| 28 |
+
RATE_LIMIT_MAX_CALLS: int = 200
|
| 29 |
+
|
| 30 |
+
# ── Bus ──────────────────────────────────────────────────────────────────────
|
| 31 |
+
BUS_HEALTH_WINDOW: int = 20 # samples per ring-buffer window
|
| 32 |
+
BUS_QUARANTINE_SECONDS: int = 60
|
| 33 |
+
BUS_FRESHNESS_SECONDS: int = 60
|
| 34 |
+
BUS_LOCAL_LOAD_THRESHOLD: float = 0.80
|
| 35 |
+
|
| 36 |
+
# ── Emergency detector ───────────────────────────────────────────────────────
|
| 37 |
+
EMERGENCY_PROBE_INTERVAL_ONLINE_SECONDS: int = 30
|
| 38 |
+
EMERGENCY_PROBE_INTERVAL_OFFLINE_SECONDS: int = 10
|
| 39 |
+
EMERGENCY_PROBE_TIMEOUT_SECONDS: int = 5
|
| 40 |
+
EMERGENCY_TRANSITION_DEBOUNCE_SECONDS: int = 5
|
| 41 |
+
EMERGENCY_ANTI_FLAP_WINDOW_SECONDS: int = 60
|
| 42 |
+
EMERGENCY_ANTI_FLAP_MAX_TRANSITIONS: int = 3
|
| 43 |
+
EMERGENCY_CLOCK_SKEW_WARN_SECONDS: int = 60
|
| 44 |
+
|
| 45 |
+
# ── Blobs ─────────────────────────────────────────────────────────────────────
|
| 46 |
+
CHUNK_SIZE_BYTES: int = 256 * 1024 # 256 KB
|
| 47 |
+
BLOB_GC_THRESHOLD: float = 0.80
|
| 48 |
+
|
| 49 |
+
# ── Events / Lamport ─────────────────────────────────────────────────────────
|
| 50 |
+
SNAPSHOT_KEEP_LAST_N: int = 7
|
| 51 |
+
|
| 52 |
+
# ── Observability ─────────────────────────────────────────────────────────────
|
| 53 |
+
LOG_RETENTION_DAYS: int = 14
|
| 54 |
+
TRACE_RING_BUFFER_SIZE: int = 1000
|
| 55 |
+
|
| 56 |
+
# ── Onboarding ───────────────────────────────────────────────────────────────
|
| 57 |
+
INVITE_DEFAULT_TTL_SECONDS: int = 86400 # 24 h
|
| 58 |
+
|
| 59 |
+
# ── RAG / Embedding ──────────────────────────────────────────────────────────
|
| 60 |
+
RAG_DEFAULT_CHUNK_SIZE_TOKENS: int = 512
|
| 61 |
+
EMBED_MAX_TEXTS: int = 256
|
| 62 |
+
EMBED_MAX_CHARS: int = 8192
|
| 63 |
+
RAG_OVERLAP_TOKENS: int = 64
|
| 64 |
+
EMBED_MAX_TEXTS: int = 256
|
| 65 |
+
EMBED_MAX_CHARS: int = 8192
|
| 66 |
+
EMBED_DEFAULT_MODEL: str = "BAAI/bge-small-en-v1.5"
|
| 67 |
+
|
| 68 |
+
# ── LLM ──────────────────────────────────────────────────────────────────────
|
| 69 |
+
LLM_STREAM_CANCEL_TIMEOUT_MS: int = 200
|
| 70 |
+
|
| 71 |
+
# ── Marketplace ──────────────────────────────────────────────────────────────
|
| 72 |
+
MARKET_SWEEP_INTERVAL_SECONDS: int = 60
|
| 73 |
+
MARKET_DEFAULT_TTL_SECONDS: int = 86400 * 7 # 1 week
|
| 74 |
+
MARKET_MAX_TTL_SECONDS: int = 86400 * 30 # 30 days
|
| 75 |
+
MARKET_SEARCH_CACHE_MAX: int = 5000
|
|
@@ -1,3 +1,9 @@
|
|
| 1 |
-
from hearthnet.discovery.
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
__all__ = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from hearthnet.discovery.mdns import MdnsAnnouncer, MdnsBrowser
|
| 2 |
+
from hearthnet.discovery.peers import PeerEvent, PeerRecord, PeerRegistry
|
| 3 |
+
from hearthnet.discovery.udp import UdpAnnouncer, UdpListener
|
| 4 |
|
| 5 |
+
__all__ = [
|
| 6 |
+
"PeerRecord", "PeerRegistry", "PeerEvent",
|
| 7 |
+
"MdnsAnnouncer", "MdnsBrowser",
|
| 8 |
+
"UdpAnnouncer", "UdpListener",
|
| 9 |
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import time
|
| 5 |
+
|
| 6 |
+
# Optional: python-zeroconf
|
| 7 |
+
try:
|
| 8 |
+
from zeroconf import ServiceInfo
|
| 9 |
+
from zeroconf.asyncio import AsyncServiceBrowser, AsyncZeroconf
|
| 10 |
+
HAS_ZEROCONF = True
|
| 11 |
+
except ImportError:
|
| 12 |
+
HAS_ZEROCONF = False
|
| 13 |
+
|
| 14 |
+
from hearthnet.constants import MDNS_SERVICE_TYPE
|
| 15 |
+
from hearthnet.discovery.peers import PeerRecord, PeerRegistry
|
| 16 |
+
from hearthnet.types import Endpoint
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class MdnsAnnouncer:
|
| 20 |
+
"""Publishes our own service via mDNS. No-op if zeroconf not available."""
|
| 21 |
+
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
registry: PeerRegistry,
|
| 25 |
+
node_id: str,
|
| 26 |
+
display_name: str,
|
| 27 |
+
port: int = 7080,
|
| 28 |
+
properties: dict | None = None,
|
| 29 |
+
) -> None:
|
| 30 |
+
self._registry = registry
|
| 31 |
+
self._node_id = node_id
|
| 32 |
+
self._display_name = display_name
|
| 33 |
+
self._port = port
|
| 34 |
+
self._properties = properties or {}
|
| 35 |
+
self._zeroconf = None
|
| 36 |
+
self._info = None
|
| 37 |
+
|
| 38 |
+
async def start(self) -> None:
|
| 39 |
+
if not HAS_ZEROCONF:
|
| 40 |
+
return
|
| 41 |
+
try:
|
| 42 |
+
import socket
|
| 43 |
+
self._zeroconf = AsyncZeroconf()
|
| 44 |
+
short = self._node_id.replace("ed25519:", "")[:8]
|
| 45 |
+
name = f"{self._display_name[:20]}-{short}.{MDNS_SERVICE_TYPE}"
|
| 46 |
+
props = {
|
| 47 |
+
"v": "1",
|
| 48 |
+
"node": self._node_id,
|
| 49 |
+
"profile": self._properties.get("profile", "hearth"),
|
| 50 |
+
"caps": ",".join(self._properties.get("caps", [])),
|
| 51 |
+
"contract_version": "1.0",
|
| 52 |
+
}
|
| 53 |
+
self._info = ServiceInfo(
|
| 54 |
+
MDNS_SERVICE_TYPE,
|
| 55 |
+
name,
|
| 56 |
+
addresses=[socket.inet_aton("127.0.0.1")],
|
| 57 |
+
port=self._port,
|
| 58 |
+
properties={k: v.encode() for k, v in props.items()},
|
| 59 |
+
)
|
| 60 |
+
await self._zeroconf.async_register_service(self._info)
|
| 61 |
+
except Exception:
|
| 62 |
+
pass # mDNS failure is non-fatal
|
| 63 |
+
|
| 64 |
+
async def stop(self) -> None:
|
| 65 |
+
if self._zeroconf and self._info:
|
| 66 |
+
try:
|
| 67 |
+
await self._zeroconf.async_unregister_service(self._info)
|
| 68 |
+
await self._zeroconf.async_close()
|
| 69 |
+
except Exception:
|
| 70 |
+
pass
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class MdnsBrowser:
|
| 74 |
+
"""Listens for other HearthNet nodes via mDNS, populates the registry."""
|
| 75 |
+
|
| 76 |
+
def __init__(self, registry: PeerRegistry, our_community_id: str) -> None:
|
| 77 |
+
self._registry = registry
|
| 78 |
+
self._community_id = our_community_id
|
| 79 |
+
self._zeroconf = None
|
| 80 |
+
self._browser = None
|
| 81 |
+
|
| 82 |
+
async def start(self) -> None:
|
| 83 |
+
if not HAS_ZEROCONF:
|
| 84 |
+
return
|
| 85 |
+
try:
|
| 86 |
+
self._zeroconf = AsyncZeroconf()
|
| 87 |
+
self._browser = AsyncServiceBrowser(
|
| 88 |
+
self._zeroconf.zeroconf,
|
| 89 |
+
MDNS_SERVICE_TYPE,
|
| 90 |
+
handlers=[self._on_service_state_change],
|
| 91 |
+
)
|
| 92 |
+
except Exception:
|
| 93 |
+
pass
|
| 94 |
+
|
| 95 |
+
def _on_service_state_change(self, zeroconf, service_type, name, state_change) -> None:
|
| 96 |
+
asyncio.create_task(self._handle_change(zeroconf, service_type, name, state_change))
|
| 97 |
+
|
| 98 |
+
async def _handle_change(self, zeroconf, service_type, name, state_change) -> None:
|
| 99 |
+
try:
|
| 100 |
+
from zeroconf import ServiceStateChange
|
| 101 |
+
if state_change in (ServiceStateChange.Added, ServiceStateChange.Updated):
|
| 102 |
+
info = await zeroconf.async_get_service_info(service_type, name)
|
| 103 |
+
if info:
|
| 104 |
+
props = {
|
| 105 |
+
k.decode(): v.decode()
|
| 106 |
+
for k, v in info.properties.items()
|
| 107 |
+
if isinstance(k, bytes)
|
| 108 |
+
}
|
| 109 |
+
node_id = props.get("node", "")
|
| 110 |
+
if not node_id:
|
| 111 |
+
return
|
| 112 |
+
import socket
|
| 113 |
+
addresses = [socket.inet_ntoa(a) for a in info.addresses]
|
| 114 |
+
host = addresses[0] if addresses else "127.0.0.1"
|
| 115 |
+
record = PeerRecord(
|
| 116 |
+
node_id_full=node_id,
|
| 117 |
+
display_name=name.split(".")[0],
|
| 118 |
+
community_id=props.get("community", ""),
|
| 119 |
+
profile=props.get("profile", "hearth"),
|
| 120 |
+
endpoints=[Endpoint("https", host, info.port)],
|
| 121 |
+
last_seen=time.monotonic(),
|
| 122 |
+
source="mdns",
|
| 123 |
+
)
|
| 124 |
+
self._registry.upsert(record)
|
| 125 |
+
# ServiceStateChange.Removed: let pruner handle it
|
| 126 |
+
except Exception:
|
| 127 |
+
pass
|
| 128 |
+
|
| 129 |
+
async def stop(self) -> None:
|
| 130 |
+
if self._zeroconf:
|
| 131 |
+
try:
|
| 132 |
+
await self._zeroconf.async_close()
|
| 133 |
+
except Exception:
|
| 134 |
+
pass
|
|
@@ -1,8 +1,9 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import time
|
| 4 |
from dataclasses import dataclass, field
|
| 5 |
-
from typing import Any
|
| 6 |
|
| 7 |
from hearthnet.types import CommunityID, Endpoint, NodeID, Profile
|
| 8 |
|
|
@@ -16,12 +17,13 @@ class PeerRecord:
|
|
| 16 |
endpoints: list[Endpoint] = field(default_factory=list)
|
| 17 |
manifest: dict[str, Any] | None = None
|
| 18 |
last_seen: float = field(default_factory=time.monotonic)
|
| 19 |
-
|
| 20 |
-
|
| 21 |
|
| 22 |
@property
|
| 23 |
def node_id(self) -> str:
|
| 24 |
-
|
|
|
|
| 25 |
|
| 26 |
def as_view(self) -> dict[str, Any]:
|
| 27 |
return {
|
|
@@ -29,43 +31,101 @@ class PeerRecord:
|
|
| 29 |
"display_name": self.display_name,
|
| 30 |
"community_id": self.community_id,
|
| 31 |
"profile": self.profile,
|
| 32 |
-
"
|
| 33 |
"source": self.source,
|
| 34 |
-
"rtt_ms": self.rtt_ms,
|
| 35 |
}
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
class PeerRegistry:
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
self.community_id = community_id
|
| 42 |
self._peers: dict[NodeID, PeerRecord] = {}
|
| 43 |
-
self.
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
def upsert(self, record: PeerRecord) -> bool:
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
is_new = record.node_id_full not in self._peers
|
| 49 |
-
record.last_seen = time.monotonic()
|
| 50 |
self._peers[record.node_id_full] = record
|
|
|
|
|
|
|
|
|
|
| 51 |
return is_new
|
| 52 |
|
| 53 |
-
def remove(self,
|
| 54 |
-
|
|
|
|
|
|
|
| 55 |
|
| 56 |
-
def get(self,
|
| 57 |
-
return self._peers.get(
|
| 58 |
|
| 59 |
def all(self) -> list[PeerRecord]:
|
| 60 |
return list(self._peers.values())
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def prune_stale(self, max_age_seconds: int | None = None) -> int:
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
return len(stale)
|
| 69 |
|
| 70 |
-
def
|
| 71 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
import time
|
| 5 |
from dataclasses import dataclass, field
|
| 6 |
+
from typing import Any, AsyncIterator
|
| 7 |
|
| 8 |
from hearthnet.types import CommunityID, Endpoint, NodeID, Profile
|
| 9 |
|
|
|
|
| 17 |
endpoints: list[Endpoint] = field(default_factory=list)
|
| 18 |
manifest: dict[str, Any] | None = None
|
| 19 |
last_seen: float = field(default_factory=time.monotonic)
|
| 20 |
+
source: str = "memory" # "mdns" | "udp" | "relay" | "memory"
|
| 21 |
+
latency_ms: float = 0.0
|
| 22 |
|
| 23 |
@property
|
| 24 |
def node_id(self) -> str:
|
| 25 |
+
"""Short form: first 12 chars after 'ed25519:' or the full node_id if short."""
|
| 26 |
+
return self.node_id_full
|
| 27 |
|
| 28 |
def as_view(self) -> dict[str, Any]:
|
| 29 |
return {
|
|
|
|
| 31 |
"display_name": self.display_name,
|
| 32 |
"community_id": self.community_id,
|
| 33 |
"profile": self.profile,
|
| 34 |
+
"last_seen": self.last_seen,
|
| 35 |
"source": self.source,
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
|
| 39 |
+
@dataclass(frozen=True)
|
| 40 |
+
class PeerEvent:
|
| 41 |
+
kind: str # "added" | "removed" | "updated"
|
| 42 |
+
peer: PeerRecord
|
| 43 |
+
|
| 44 |
+
|
| 45 |
class PeerRegistry:
|
| 46 |
+
"""In-memory map of NodeID → PeerRecord. Thread-safe via asyncio.Lock."""
|
| 47 |
+
|
| 48 |
+
def __init__(self, our_node_id: str, community_id: str) -> None:
|
| 49 |
+
self.our_node_id = our_node_id
|
| 50 |
+
# Keep legacy attribute name for backward compatibility
|
| 51 |
+
self.our_node_id_full = our_node_id
|
| 52 |
self.community_id = community_id
|
| 53 |
self._peers: dict[NodeID, PeerRecord] = {}
|
| 54 |
+
self._lock = asyncio.Lock()
|
| 55 |
+
self._subscribers: list[asyncio.Queue] = []
|
| 56 |
+
self._pruning_aggressive = False
|
| 57 |
+
self._pruning_task: asyncio.Task | None = None
|
| 58 |
|
| 59 |
def upsert(self, record: PeerRecord) -> bool:
|
| 60 |
+
"""Add or update peer. Returns True if new peer was added."""
|
| 61 |
+
existing = self._peers.get(record.node_id_full)
|
|
|
|
|
|
|
| 62 |
self._peers[record.node_id_full] = record
|
| 63 |
+
is_new = existing is None
|
| 64 |
+
event_kind = "added" if is_new else "updated"
|
| 65 |
+
self._notify(PeerEvent(kind=event_kind, peer=record))
|
| 66 |
return is_new
|
| 67 |
|
| 68 |
+
def remove(self, node_id: str) -> None:
|
| 69 |
+
peer = self._peers.pop(node_id, None)
|
| 70 |
+
if peer:
|
| 71 |
+
self._notify(PeerEvent(kind="removed", peer=peer))
|
| 72 |
|
| 73 |
+
def get(self, node_id: str) -> PeerRecord | None:
|
| 74 |
+
return self._peers.get(node_id)
|
| 75 |
|
| 76 |
def all(self) -> list[PeerRecord]:
|
| 77 |
return list(self._peers.values())
|
| 78 |
|
| 79 |
+
def count(self) -> int:
|
| 80 |
+
return len(self._peers)
|
| 81 |
+
|
| 82 |
+
def set_pruning_aggressive(self, aggressive: bool) -> None:
|
| 83 |
+
self._pruning_aggressive = aggressive
|
| 84 |
+
|
| 85 |
+
@property
|
| 86 |
+
def prune_stale_seconds(self) -> int:
|
| 87 |
+
from hearthnet.constants import PEER_PRUNE_AGGRESSIVE_SECONDS, PEER_PRUNE_NORMAL_SECONDS
|
| 88 |
+
|
| 89 |
+
return PEER_PRUNE_AGGRESSIVE_SECONDS if self._pruning_aggressive else PEER_PRUNE_NORMAL_SECONDS
|
| 90 |
+
|
| 91 |
def prune_stale(self, max_age_seconds: int | None = None) -> int:
|
| 92 |
+
"""Remove peers whose last_seen is beyond the prune threshold."""
|
| 93 |
+
from hearthnet.constants import PEER_PRUNE_AGGRESSIVE_SECONDS, PEER_PRUNE_NORMAL_SECONDS
|
| 94 |
+
if max_age_seconds is not None:
|
| 95 |
+
threshold = max_age_seconds
|
| 96 |
+
else:
|
| 97 |
+
threshold = PEER_PRUNE_AGGRESSIVE_SECONDS if self._pruning_aggressive else PEER_PRUNE_NORMAL_SECONDS
|
| 98 |
+
now = time.monotonic()
|
| 99 |
+
stale = [nid for nid, peer in self._peers.items() if now - peer.last_seen > threshold]
|
| 100 |
+
for nid in stale:
|
| 101 |
+
self.remove(nid)
|
| 102 |
return len(stale)
|
| 103 |
|
| 104 |
+
async def start_pruner(self) -> None:
|
| 105 |
+
self._pruning_task = asyncio.create_task(self._pruner_loop(), name="peer-pruner")
|
| 106 |
+
|
| 107 |
+
async def _pruner_loop(self) -> None:
|
| 108 |
+
while True:
|
| 109 |
+
await asyncio.sleep(30)
|
| 110 |
+
self.prune_stale()
|
| 111 |
+
|
| 112 |
+
def subscribe(self) -> AsyncIterator[PeerEvent]:
|
| 113 |
+
q: asyncio.Queue = asyncio.Queue(maxsize=50)
|
| 114 |
+
self._subscribers.append(q)
|
| 115 |
+
|
| 116 |
+
async def gen() -> AsyncIterator[PeerEvent]:
|
| 117 |
+
try:
|
| 118 |
+
while True:
|
| 119 |
+
event = await q.get()
|
| 120 |
+
yield event
|
| 121 |
+
finally:
|
| 122 |
+
self._subscribers.remove(q)
|
| 123 |
+
|
| 124 |
+
return gen()
|
| 125 |
+
|
| 126 |
+
def _notify(self, event: PeerEvent) -> None:
|
| 127 |
+
for q in list(self._subscribers):
|
| 128 |
+
try:
|
| 129 |
+
q.put_nowait(event)
|
| 130 |
+
except asyncio.QueueFull:
|
| 131 |
+
pass
|
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class RelayDiscovery:
|
| 5 |
+
"""Phase 2 stub: relay-based peer discovery."""
|
| 6 |
+
|
| 7 |
+
async def start(self) -> None:
|
| 8 |
+
raise NotImplementedError("Relay discovery is Phase 2")
|
| 9 |
+
|
| 10 |
+
async def stop(self) -> None:
|
| 11 |
+
pass
|
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
from hearthnet.constants import (
|
| 8 |
+
UDP_ANNOUNCE_INTERVAL_OFFLINE_SECONDS,
|
| 9 |
+
UDP_ANNOUNCE_INTERVAL_ONLINE_SECONDS,
|
| 10 |
+
UDP_MULTICAST_GROUP,
|
| 11 |
+
UDP_MULTICAST_PORT,
|
| 12 |
+
)
|
| 13 |
+
from hearthnet.discovery.peers import PeerRecord, PeerRegistry
|
| 14 |
+
from hearthnet.types import Endpoint
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class UdpAnnouncer:
|
| 18 |
+
"""Periodic UDP multicast of node presence."""
|
| 19 |
+
|
| 20 |
+
def __init__(
|
| 21 |
+
self,
|
| 22 |
+
registry: PeerRegistry,
|
| 23 |
+
node_id: str,
|
| 24 |
+
community_id: str,
|
| 25 |
+
port: int = 7080,
|
| 26 |
+
caps: list[str] | None = None,
|
| 27 |
+
) -> None:
|
| 28 |
+
self._registry = registry
|
| 29 |
+
self._node_id = node_id
|
| 30 |
+
self._community_id = community_id
|
| 31 |
+
self._port = port
|
| 32 |
+
self._caps = caps or []
|
| 33 |
+
self._running = False
|
| 34 |
+
self._task: asyncio.Task | None = None
|
| 35 |
+
self._offline = False
|
| 36 |
+
|
| 37 |
+
def set_offline(self, offline: bool) -> None:
|
| 38 |
+
self._offline = offline
|
| 39 |
+
|
| 40 |
+
async def start(self) -> None:
|
| 41 |
+
self._running = True
|
| 42 |
+
self._task = asyncio.create_task(self._announce_loop(), name="udp-announcer")
|
| 43 |
+
|
| 44 |
+
async def stop(self) -> None:
|
| 45 |
+
self._running = False
|
| 46 |
+
if self._task:
|
| 47 |
+
self._task.cancel()
|
| 48 |
+
try:
|
| 49 |
+
await self._task
|
| 50 |
+
except asyncio.CancelledError:
|
| 51 |
+
pass
|
| 52 |
+
|
| 53 |
+
async def _announce_loop(self) -> None:
|
| 54 |
+
while self._running:
|
| 55 |
+
await self._announce_once()
|
| 56 |
+
interval = (
|
| 57 |
+
UDP_ANNOUNCE_INTERVAL_OFFLINE_SECONDS
|
| 58 |
+
if self._offline
|
| 59 |
+
else UDP_ANNOUNCE_INTERVAL_ONLINE_SECONDS
|
| 60 |
+
)
|
| 61 |
+
await asyncio.sleep(interval)
|
| 62 |
+
|
| 63 |
+
async def _announce_once(self) -> None:
|
| 64 |
+
try:
|
| 65 |
+
import socket
|
| 66 |
+
short_id = self._node_id[8:20] if len(self._node_id) > 8 else self._node_id
|
| 67 |
+
payload = json.dumps({
|
| 68 |
+
"v": 1,
|
| 69 |
+
"node": short_id,
|
| 70 |
+
"community": self._community_id[:20],
|
| 71 |
+
"port": self._port,
|
| 72 |
+
"caps": self._caps[:10],
|
| 73 |
+
}).encode()
|
| 74 |
+
if len(payload) > 1024:
|
| 75 |
+
payload = payload[:1024]
|
| 76 |
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
|
| 77 |
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
|
| 78 |
+
sock.sendto(payload, (UDP_MULTICAST_GROUP, UDP_MULTICAST_PORT))
|
| 79 |
+
sock.close()
|
| 80 |
+
except Exception:
|
| 81 |
+
pass # UDP failure is non-fatal
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class UdpListener:
|
| 85 |
+
"""Receives UDP multicast announcements, populates registry."""
|
| 86 |
+
|
| 87 |
+
def __init__(
|
| 88 |
+
self,
|
| 89 |
+
registry: PeerRegistry,
|
| 90 |
+
our_community_id: str,
|
| 91 |
+
port: int = UDP_MULTICAST_PORT,
|
| 92 |
+
) -> None:
|
| 93 |
+
self._registry = registry
|
| 94 |
+
self._community_id = our_community_id
|
| 95 |
+
self._port = port
|
| 96 |
+
self._running = False
|
| 97 |
+
self._task: asyncio.Task | None = None
|
| 98 |
+
|
| 99 |
+
async def start(self) -> None:
|
| 100 |
+
self._running = True
|
| 101 |
+
self._task = asyncio.create_task(self._listen_loop(), name="udp-listener")
|
| 102 |
+
|
| 103 |
+
async def stop(self) -> None:
|
| 104 |
+
self._running = False
|
| 105 |
+
if self._task:
|
| 106 |
+
self._task.cancel()
|
| 107 |
+
try:
|
| 108 |
+
await self._task
|
| 109 |
+
except asyncio.CancelledError:
|
| 110 |
+
pass
|
| 111 |
+
|
| 112 |
+
async def _listen_loop(self) -> None:
|
| 113 |
+
import socket
|
| 114 |
+
import struct
|
| 115 |
+
try:
|
| 116 |
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
| 117 |
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
| 118 |
+
try:
|
| 119 |
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) # type: ignore[attr-defined]
|
| 120 |
+
except (AttributeError, OSError):
|
| 121 |
+
pass
|
| 122 |
+
sock.bind(("", self._port))
|
| 123 |
+
mcast_req = struct.pack("4sL", socket.inet_aton(UDP_MULTICAST_GROUP), socket.INADDR_ANY)
|
| 124 |
+
sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mcast_req)
|
| 125 |
+
sock.setblocking(False)
|
| 126 |
+
loop = asyncio.get_event_loop()
|
| 127 |
+
while self._running:
|
| 128 |
+
try:
|
| 129 |
+
data, addr = await loop.run_in_executor(None, sock.recvfrom, 2048)
|
| 130 |
+
await self._handle_packet(data, addr[0])
|
| 131 |
+
except Exception:
|
| 132 |
+
await asyncio.sleep(0.1)
|
| 133 |
+
except Exception:
|
| 134 |
+
pass
|
| 135 |
+
|
| 136 |
+
async def _handle_packet(self, data: bytes, source_ip: str) -> None:
|
| 137 |
+
try:
|
| 138 |
+
msg = json.loads(data.decode())
|
| 139 |
+
if msg.get("v") != 1:
|
| 140 |
+
return
|
| 141 |
+
community = msg.get("community", "")
|
| 142 |
+
if community and not self._community_id.startswith(community[:10]):
|
| 143 |
+
return
|
| 144 |
+
node_id = msg.get("node", "")
|
| 145 |
+
if not node_id:
|
| 146 |
+
return
|
| 147 |
+
port = int(msg.get("port", 7080))
|
| 148 |
+
record = PeerRecord(
|
| 149 |
+
node_id_full=node_id,
|
| 150 |
+
display_name=node_id[:12],
|
| 151 |
+
community_id=community,
|
| 152 |
+
profile="hearth",
|
| 153 |
+
endpoints=[Endpoint("https", source_ip, port)],
|
| 154 |
+
last_seen=time.monotonic(),
|
| 155 |
+
source="udp",
|
| 156 |
+
)
|
| 157 |
+
self._registry.upsert(record)
|
| 158 |
+
except Exception:
|
| 159 |
+
pass
|
|
@@ -1,23 +1,164 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
from hearthnet.emergency.state import EmergencyState, StateBus
|
| 6 |
|
| 7 |
|
| 8 |
class Detector:
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
self
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
def apply_probe_results(self, probe_results: dict[str, bool]) -> EmergencyState:
|
| 15 |
-
|
| 16 |
-
|
|
|
|
| 17 |
if previous != "offline" and state.mode == "offline":
|
| 18 |
-
self.
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
return state
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
+
import socket
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from hearthnet.constants import (
|
| 8 |
+
EMERGENCY_PROBE_INTERVAL_OFFLINE_SECONDS,
|
| 9 |
+
EMERGENCY_PROBE_INTERVAL_ONLINE_SECONDS,
|
| 10 |
+
EMERGENCY_PROBE_TIMEOUT_SECONDS,
|
| 11 |
+
)
|
| 12 |
from hearthnet.emergency.state import EmergencyState, StateBus
|
| 13 |
|
| 14 |
|
| 15 |
class Detector:
|
| 16 |
+
"""Internet connectivity detector with async probe loop."""
|
| 17 |
+
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
bus: Any = None,
|
| 21 |
+
state_bus: StateBus | None = None,
|
| 22 |
+
peers: Any = None,
|
| 23 |
+
probe_targets: list[str] | None = None,
|
| 24 |
+
) -> None:
|
| 25 |
+
self._bus = bus
|
| 26 |
+
self._state_bus = state_bus or StateBus()
|
| 27 |
+
self._peers = peers
|
| 28 |
+
self._probe_targets = probe_targets or [
|
| 29 |
+
"1.1.1.1",
|
| 30 |
+
"8.8.8.8",
|
| 31 |
+
"https://cloudflare.com",
|
| 32 |
+
"https://quad9.net",
|
| 33 |
+
]
|
| 34 |
+
self._running = False
|
| 35 |
+
self._task: asyncio.Task | None = None
|
| 36 |
+
|
| 37 |
+
@property
|
| 38 |
+
def state_bus(self) -> StateBus:
|
| 39 |
+
return self._state_bus
|
| 40 |
+
|
| 41 |
+
async def start(self) -> None:
|
| 42 |
+
"""Start the background probe loop."""
|
| 43 |
+
self._running = True
|
| 44 |
+
self._task = asyncio.create_task(self._probe_loop(), name="emergency-detector")
|
| 45 |
+
|
| 46 |
+
async def stop(self) -> None:
|
| 47 |
+
self._running = False
|
| 48 |
+
if self._task:
|
| 49 |
+
self._task.cancel()
|
| 50 |
+
try:
|
| 51 |
+
await self._task
|
| 52 |
+
except asyncio.CancelledError:
|
| 53 |
+
pass
|
| 54 |
+
|
| 55 |
+
async def _probe_loop(self) -> None:
|
| 56 |
+
while self._running:
|
| 57 |
+
results = await self._probe_all()
|
| 58 |
+
previous = self._state_bus.current().mode
|
| 59 |
+
state = self._state_bus.emit_probe(results)
|
| 60 |
+
|
| 61 |
+
if previous != "offline" and state.mode == "offline":
|
| 62 |
+
await self._on_offline()
|
| 63 |
+
elif previous == "offline" and state.mode in ("online", "degraded"):
|
| 64 |
+
await self._on_restore()
|
| 65 |
+
|
| 66 |
+
interval = (
|
| 67 |
+
EMERGENCY_PROBE_INTERVAL_OFFLINE_SECONDS
|
| 68 |
+
if state.mode == "offline"
|
| 69 |
+
else EMERGENCY_PROBE_INTERVAL_ONLINE_SECONDS
|
| 70 |
+
)
|
| 71 |
+
await asyncio.sleep(interval)
|
| 72 |
+
|
| 73 |
+
async def _probe_all(self) -> dict[str, bool]:
|
| 74 |
+
tasks = {
|
| 75 |
+
target: asyncio.create_task(self._probe_one(target))
|
| 76 |
+
for target in self._probe_targets
|
| 77 |
+
}
|
| 78 |
+
results: dict[str, bool] = {}
|
| 79 |
+
for target, task in tasks.items():
|
| 80 |
+
try:
|
| 81 |
+
results[target] = await asyncio.wait_for(task, timeout=EMERGENCY_PROBE_TIMEOUT_SECONDS)
|
| 82 |
+
except (asyncio.TimeoutError, Exception):
|
| 83 |
+
results[target] = False
|
| 84 |
+
return results
|
| 85 |
+
|
| 86 |
+
async def _probe_one(self, target: str) -> bool:
|
| 87 |
+
"""Probe a single target. DNS targets: resolve host. HTTP targets: HEAD request."""
|
| 88 |
+
try:
|
| 89 |
+
if target.startswith("http"):
|
| 90 |
+
return await self._probe_http(target)
|
| 91 |
+
return await self._probe_dns(target)
|
| 92 |
+
except Exception:
|
| 93 |
+
return False
|
| 94 |
+
|
| 95 |
+
async def _probe_http(self, url: str) -> bool:
|
| 96 |
+
try:
|
| 97 |
+
import httpx
|
| 98 |
+
|
| 99 |
+
async with httpx.AsyncClient(
|
| 100 |
+
timeout=EMERGENCY_PROBE_TIMEOUT_SECONDS # verify=True (default) — certificate
|
| 101 |
+
# validation is intentional: we want to know if TLS infra is working too.
|
| 102 |
+
) as client:
|
| 103 |
+
resp = await client.head(url)
|
| 104 |
+
return resp.status_code < 500
|
| 105 |
+
except ImportError:
|
| 106 |
+
import urllib.request
|
| 107 |
+
|
| 108 |
+
try:
|
| 109 |
+
urllib.request.urlopen(url, timeout=EMERGENCY_PROBE_TIMEOUT_SECONDS)
|
| 110 |
+
return True
|
| 111 |
+
except Exception:
|
| 112 |
+
return False
|
| 113 |
+
except Exception:
|
| 114 |
+
return False
|
| 115 |
+
|
| 116 |
+
async def _probe_dns(self, host: str) -> bool:
|
| 117 |
+
try:
|
| 118 |
+
loop = asyncio.get_event_loop()
|
| 119 |
+
await loop.run_in_executor(None, socket.getaddrinfo, host, 53)
|
| 120 |
+
return True
|
| 121 |
+
except Exception:
|
| 122 |
+
return False
|
| 123 |
+
|
| 124 |
+
async def _on_offline(self) -> None:
|
| 125 |
+
"""Deregister internet-dependent capabilities, increase peer pruning aggressiveness."""
|
| 126 |
+
if self._bus is not None:
|
| 127 |
+
try:
|
| 128 |
+
self._bus.deregister_internet_capabilities()
|
| 129 |
+
except Exception:
|
| 130 |
+
pass
|
| 131 |
+
if self._peers is not None:
|
| 132 |
+
try:
|
| 133 |
+
self._peers.set_pruning_aggressive(True)
|
| 134 |
+
except Exception:
|
| 135 |
+
pass
|
| 136 |
+
|
| 137 |
+
async def _on_restore(self) -> None:
|
| 138 |
+
"""Restore internet-dependent capabilities."""
|
| 139 |
+
if self._bus is not None:
|
| 140 |
+
try:
|
| 141 |
+
self._bus.restore_internet_capabilities()
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
+
if self._peers is not None:
|
| 145 |
+
try:
|
| 146 |
+
self._peers.set_pruning_aggressive(False)
|
| 147 |
+
except Exception:
|
| 148 |
+
pass
|
| 149 |
|
| 150 |
def apply_probe_results(self, probe_results: dict[str, bool]) -> EmergencyState:
|
| 151 |
+
"""Synchronous interface for manual/test use."""
|
| 152 |
+
previous = self._state_bus.current().mode
|
| 153 |
+
state = self._state_bus.emit_probe(probe_results)
|
| 154 |
if previous != "offline" and state.mode == "offline":
|
| 155 |
+
if self._bus is not None:
|
| 156 |
+
self._bus.deregister_internet_capabilities()
|
| 157 |
+
if self._peers is not None:
|
| 158 |
+
self._peers.set_pruning_aggressive(True)
|
| 159 |
+
elif previous == "offline" and state.mode in ("online", "degraded"):
|
| 160 |
+
if self._bus is not None:
|
| 161 |
+
self._bus.restore_internet_capabilities()
|
| 162 |
+
if self._peers is not None:
|
| 163 |
+
self._peers.set_pruning_aggressive(False)
|
| 164 |
return state
|
|
@@ -1,43 +1,101 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
from
|
|
|
|
| 6 |
|
| 7 |
Mode = Literal["online", "degraded", "offline"]
|
| 8 |
|
| 9 |
|
| 10 |
-
def utc_now() -> str:
|
| 11 |
-
return datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
| 12 |
-
|
| 13 |
-
|
| 14 |
@dataclass(frozen=True)
|
| 15 |
class EmergencyState:
|
| 16 |
mode: Mode
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
class StateBus:
|
|
|
|
|
|
|
| 23 |
def __init__(self) -> None:
|
| 24 |
-
|
| 25 |
-
self.
|
|
|
|
| 26 |
|
| 27 |
def current(self) -> EmergencyState:
|
| 28 |
return self._state
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
def emit_probe(self, probe_results: dict[str, bool]) -> EmergencyState:
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
else:
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
| 42 |
)
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
+
import time
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import AsyncIterator, Literal
|
| 7 |
|
| 8 |
Mode = Literal["online", "degraded", "offline"]
|
| 9 |
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
@dataclass(frozen=True)
|
| 12 |
class EmergencyState:
|
| 13 |
mode: Mode
|
| 14 |
+
changed_at: float # monotonic timestamp
|
| 15 |
+
probe_results: dict[str, bool] # target -> success
|
| 16 |
+
consecutive_fails: int = 0
|
| 17 |
+
|
| 18 |
+
@property
|
| 19 |
+
def mode_label(self) -> str:
|
| 20 |
+
return {
|
| 21 |
+
"online": "ONLINE",
|
| 22 |
+
"degraded": "DEGRADED — LIMITED",
|
| 23 |
+
"offline": "INTERNET OFFLINE — LOKAL AKTIV",
|
| 24 |
+
}[self.mode]
|
| 25 |
|
| 26 |
|
| 27 |
class StateBus:
|
| 28 |
+
"""In-process pub/sub for emergency state changes."""
|
| 29 |
+
|
| 30 |
def __init__(self) -> None:
|
| 31 |
+
self._state = EmergencyState(mode="online", changed_at=time.monotonic(), probe_results={})
|
| 32 |
+
self._subscribers: list[asyncio.Queue] = []
|
| 33 |
+
self._transition_times: list[float] = [] # for anti-flap
|
| 34 |
|
| 35 |
def current(self) -> EmergencyState:
|
| 36 |
return self._state
|
| 37 |
|
| 38 |
+
async def subscribe(self) -> AsyncIterator[EmergencyState]:
|
| 39 |
+
q: asyncio.Queue = asyncio.Queue(maxsize=10)
|
| 40 |
+
self._subscribers.append(q)
|
| 41 |
+
try:
|
| 42 |
+
while True:
|
| 43 |
+
state = await q.get()
|
| 44 |
+
yield state
|
| 45 |
+
finally:
|
| 46 |
+
self._subscribers.remove(q)
|
| 47 |
+
|
| 48 |
def emit_probe(self, probe_results: dict[str, bool]) -> EmergencyState:
|
| 49 |
+
"""Compute new mode from probe results, apply anti-flap, emit if changed."""
|
| 50 |
+
successes = sum(1 for v in probe_results.values() if v)
|
| 51 |
+
total = len(probe_results)
|
| 52 |
+
fails = total - successes
|
| 53 |
+
|
| 54 |
+
if total == 0:
|
| 55 |
+
new_mode: Mode = "online"
|
| 56 |
+
elif fails >= max(2, total // 2):
|
| 57 |
+
new_mode = "offline"
|
| 58 |
+
elif fails > 0:
|
| 59 |
+
new_mode = "degraded"
|
| 60 |
else:
|
| 61 |
+
new_mode = "online"
|
| 62 |
+
|
| 63 |
+
old_mode = self._state.mode
|
| 64 |
+
|
| 65 |
+
# Anti-flap: if too many transitions in last 60s, stay pessimistic
|
| 66 |
+
from hearthnet.constants import (
|
| 67 |
+
EMERGENCY_ANTI_FLAP_MAX_TRANSITIONS,
|
| 68 |
+
EMERGENCY_ANTI_FLAP_WINDOW_SECONDS,
|
| 69 |
)
|
| 70 |
+
|
| 71 |
+
now = time.monotonic()
|
| 72 |
+
self._transition_times = [
|
| 73 |
+
t for t in self._transition_times if now - t < EMERGENCY_ANTI_FLAP_WINDOW_SECONDS
|
| 74 |
+
]
|
| 75 |
+
if len(self._transition_times) >= EMERGENCY_ANTI_FLAP_MAX_TRANSITIONS:
|
| 76 |
+
# Too many flaps — hold pessimistic
|
| 77 |
+
if old_mode in ("degraded", "offline") and new_mode == "online":
|
| 78 |
+
new_mode = old_mode # don't restore yet
|
| 79 |
+
|
| 80 |
+
new_state = EmergencyState(
|
| 81 |
+
mode=new_mode,
|
| 82 |
+
changed_at=now if new_mode != old_mode else self._state.changed_at,
|
| 83 |
+
probe_results=probe_results,
|
| 84 |
+
consecutive_fails=self._state.consecutive_fails + (1 if fails > 0 else 0),
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
if new_mode != old_mode:
|
| 88 |
+
self._transition_times.append(now)
|
| 89 |
+
self._state = new_state
|
| 90 |
+
self._emit(new_state)
|
| 91 |
+
else:
|
| 92 |
+
self._state = new_state
|
| 93 |
+
|
| 94 |
+
return new_state
|
| 95 |
+
|
| 96 |
+
def _emit(self, state: EmergencyState) -> None:
|
| 97 |
+
for q in list(self._subscribers):
|
| 98 |
+
try:
|
| 99 |
+
q.put_nowait(state)
|
| 100 |
+
except asyncio.QueueFull:
|
| 101 |
+
pass
|
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from .lamport import LamportClock
|
| 4 |
+
from .log import EventLog, EventLogError
|
| 5 |
+
from .replay import MaterialisedView, ReplayEngine
|
| 6 |
+
from .snapshot import Snapshot, SnapshotStore, build_snapshot, restore_from_snapshot
|
| 7 |
+
from .sync import HeadsReport, SyncClient, SyncResult, SyncServer
|
| 8 |
+
from .types import Event, EventType, new_ulid
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"Event",
|
| 12 |
+
"EventType",
|
| 13 |
+
"EventLog",
|
| 14 |
+
"EventLogError",
|
| 15 |
+
"LamportClock",
|
| 16 |
+
"ReplayEngine",
|
| 17 |
+
"MaterialisedView",
|
| 18 |
+
"SnapshotStore",
|
| 19 |
+
"Snapshot",
|
| 20 |
+
"build_snapshot",
|
| 21 |
+
"restore_from_snapshot",
|
| 22 |
+
"SyncClient",
|
| 23 |
+
"SyncServer",
|
| 24 |
+
"HeadsReport",
|
| 25 |
+
"SyncResult",
|
| 26 |
+
"new_ulid",
|
| 27 |
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import sqlite3
|
| 4 |
+
import threading
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class LamportClock:
|
| 9 |
+
"""Thread-safe, SQLite-persisted Lamport clock for one community.
|
| 10 |
+
|
| 11 |
+
The clock row lives in the same ``clock`` table as the event log DB so
|
| 12 |
+
that both the event insert and the clock bump happen in the same
|
| 13 |
+
transaction.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
def __init__(self, community_id: str, db_path: Path) -> None:
|
| 17 |
+
self._community_id = community_id
|
| 18 |
+
self._db_path = db_path
|
| 19 |
+
self._lock = threading.Lock()
|
| 20 |
+
self._value: int = 0
|
| 21 |
+
self._conn: sqlite3.Connection | None = None
|
| 22 |
+
self._load()
|
| 23 |
+
|
| 24 |
+
# ------------------------------------------------------------------
|
| 25 |
+
# Public interface
|
| 26 |
+
# ------------------------------------------------------------------
|
| 27 |
+
|
| 28 |
+
def tick(self) -> int:
|
| 29 |
+
"""Increment and return the new Lamport value (for local events)."""
|
| 30 |
+
with self._lock:
|
| 31 |
+
self._value += 1
|
| 32 |
+
self._save()
|
| 33 |
+
return self._value
|
| 34 |
+
|
| 35 |
+
def update(self, received: int) -> int:
|
| 36 |
+
"""Advance to max(local, received) + 1 (for received events)."""
|
| 37 |
+
with self._lock:
|
| 38 |
+
self._value = max(self._value, received) + 1
|
| 39 |
+
self._save()
|
| 40 |
+
return self._value
|
| 41 |
+
|
| 42 |
+
def current(self) -> int:
|
| 43 |
+
with self._lock:
|
| 44 |
+
return self._value
|
| 45 |
+
|
| 46 |
+
# ------------------------------------------------------------------
|
| 47 |
+
# Internal helpers
|
| 48 |
+
# ------------------------------------------------------------------
|
| 49 |
+
|
| 50 |
+
def _get_conn(self) -> sqlite3.Connection:
|
| 51 |
+
if self._conn is None:
|
| 52 |
+
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
| 53 |
+
return self._conn
|
| 54 |
+
|
| 55 |
+
def _load(self) -> None:
|
| 56 |
+
conn = self._get_conn()
|
| 57 |
+
conn.execute(
|
| 58 |
+
"CREATE TABLE IF NOT EXISTS clock "
|
| 59 |
+
"(community_id TEXT PRIMARY KEY, lamport INTEGER NOT NULL)"
|
| 60 |
+
)
|
| 61 |
+
conn.commit()
|
| 62 |
+
row = conn.execute(
|
| 63 |
+
"SELECT lamport FROM clock WHERE community_id = ?",
|
| 64 |
+
(self._community_id,),
|
| 65 |
+
).fetchone()
|
| 66 |
+
self._value = row[0] if row else 0
|
| 67 |
+
|
| 68 |
+
def _save(self) -> None:
|
| 69 |
+
"""Persist current value. Called while ``_lock`` is held."""
|
| 70 |
+
conn = self._get_conn()
|
| 71 |
+
conn.execute(
|
| 72 |
+
"INSERT INTO clock (community_id, lamport) VALUES (?, ?) "
|
| 73 |
+
"ON CONFLICT(community_id) DO UPDATE SET lamport = excluded.lamport",
|
| 74 |
+
(self._community_id, self._value),
|
| 75 |
+
)
|
| 76 |
+
conn.commit()
|
| 77 |
+
|
| 78 |
+
def _save_in_tx(self, conn: sqlite3.Connection) -> None:
|
| 79 |
+
"""Persist inside an already-open transaction (no commit here)."""
|
| 80 |
+
conn.execute(
|
| 81 |
+
"INSERT INTO clock (community_id, lamport) VALUES (?, ?) "
|
| 82 |
+
"ON CONFLICT(community_id) DO UPDATE SET lamport = excluded.lamport",
|
| 83 |
+
(self._community_id, self._value),
|
| 84 |
+
)
|
|
@@ -0,0 +1,411 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
import sqlite3
|
| 6 |
+
import threading
|
| 7 |
+
from collections.abc import AsyncIterator
|
| 8 |
+
from datetime import datetime, timezone
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any
|
| 11 |
+
|
| 12 |
+
from .lamport import LamportClock
|
| 13 |
+
from .types import _ALL_EVENT_TYPES, Event, EventType, new_ulid
|
| 14 |
+
|
| 15 |
+
_SCHEMA = """
|
| 16 |
+
PRAGMA journal_mode = WAL;
|
| 17 |
+
PRAGMA synchronous = NORMAL;
|
| 18 |
+
|
| 19 |
+
CREATE TABLE IF NOT EXISTS events (
|
| 20 |
+
event_id TEXT PRIMARY KEY,
|
| 21 |
+
event_type TEXT NOT NULL,
|
| 22 |
+
community_id TEXT NOT NULL,
|
| 23 |
+
author TEXT NOT NULL,
|
| 24 |
+
lamport INTEGER NOT NULL,
|
| 25 |
+
payload TEXT NOT NULL,
|
| 26 |
+
issued_at TEXT NOT NULL,
|
| 27 |
+
signature TEXT NOT NULL,
|
| 28 |
+
schema_version INTEGER NOT NULL DEFAULT 1,
|
| 29 |
+
received_at TEXT NOT NULL
|
| 30 |
+
);
|
| 31 |
+
|
| 32 |
+
CREATE INDEX IF NOT EXISTS idx_events_lamport
|
| 33 |
+
ON events(community_id, lamport, event_id);
|
| 34 |
+
|
| 35 |
+
CREATE INDEX IF NOT EXISTS idx_events_type
|
| 36 |
+
ON events(community_id, event_type, lamport);
|
| 37 |
+
|
| 38 |
+
CREATE TABLE IF NOT EXISTS clock (
|
| 39 |
+
community_id TEXT PRIMARY KEY,
|
| 40 |
+
lamport INTEGER NOT NULL
|
| 41 |
+
);
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _now_utc() -> str:
|
| 46 |
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _row_to_event(row: tuple[Any, ...]) -> Event:
|
| 50 |
+
(
|
| 51 |
+
event_id,
|
| 52 |
+
event_type,
|
| 53 |
+
community_id,
|
| 54 |
+
author,
|
| 55 |
+
lamport,
|
| 56 |
+
payload,
|
| 57 |
+
issued_at,
|
| 58 |
+
signature,
|
| 59 |
+
schema_version,
|
| 60 |
+
_received_at,
|
| 61 |
+
) = row
|
| 62 |
+
return Event(
|
| 63 |
+
schema_version=schema_version,
|
| 64 |
+
event_id=event_id,
|
| 65 |
+
event_type=event_type, # type: ignore[arg-type]
|
| 66 |
+
community_id=community_id,
|
| 67 |
+
author=author,
|
| 68 |
+
lamport=lamport,
|
| 69 |
+
payload=json.loads(payload),
|
| 70 |
+
issued_at=issued_at,
|
| 71 |
+
signature=signature,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _sign(event: Event, kp: Any) -> str:
|
| 76 |
+
"""Return signature string or '' when kp is None."""
|
| 77 |
+
if kp is None:
|
| 78 |
+
return ""
|
| 79 |
+
import base64
|
| 80 |
+
import hashlib
|
| 81 |
+
|
| 82 |
+
raw = _canonical_bytes(event)
|
| 83 |
+
if hasattr(kp, "sign"):
|
| 84 |
+
sig_bytes: bytes = kp.sign(raw)
|
| 85 |
+
else:
|
| 86 |
+
# Fallback: HMAC-SHA256 keyed by kp as bytes (test usage)
|
| 87 |
+
import hmac
|
| 88 |
+
|
| 89 |
+
sig_bytes = hmac.new(kp, raw, hashlib.sha256).digest()
|
| 90 |
+
encoded = base64.urlsafe_b64encode(sig_bytes).rstrip(b"=").decode()
|
| 91 |
+
return f"ed25519:{encoded}"
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def _canonical_bytes(event: Event) -> bytes:
|
| 95 |
+
"""Deterministic serialisation for signing / verification."""
|
| 96 |
+
obj = {
|
| 97 |
+
"schema_version": event.schema_version,
|
| 98 |
+
"event_id": event.event_id,
|
| 99 |
+
"event_type": event.event_type,
|
| 100 |
+
"community_id": event.community_id,
|
| 101 |
+
"author": event.author,
|
| 102 |
+
"lamport": event.lamport,
|
| 103 |
+
"payload": event.payload,
|
| 104 |
+
"issued_at": event.issued_at,
|
| 105 |
+
}
|
| 106 |
+
return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _verify(event: Event, kp_store: Any) -> bool:
|
| 110 |
+
"""Return True if the signature is valid or if there is no kp_store."""
|
| 111 |
+
if kp_store is None:
|
| 112 |
+
return True
|
| 113 |
+
if not event.signature:
|
| 114 |
+
return True
|
| 115 |
+
if hasattr(kp_store, "verify"):
|
| 116 |
+
try:
|
| 117 |
+
import base64
|
| 118 |
+
|
| 119 |
+
prefix = "ed25519:"
|
| 120 |
+
b64 = event.signature[len(prefix) :] if event.signature.startswith(prefix) else event.signature
|
| 121 |
+
# pad
|
| 122 |
+
padding = 4 - len(b64) % 4
|
| 123 |
+
if padding != 4:
|
| 124 |
+
b64 += "=" * padding
|
| 125 |
+
sig_bytes = base64.urlsafe_b64decode(b64)
|
| 126 |
+
raw = _canonical_bytes(event)
|
| 127 |
+
return kp_store.verify(event.author, raw, sig_bytes)
|
| 128 |
+
except Exception:
|
| 129 |
+
return False
|
| 130 |
+
return True
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class EventLogError(Exception):
|
| 134 |
+
"""Raised for protocol violations in the event log."""
|
| 135 |
+
|
| 136 |
+
def __init__(self, code: str, message: str = "") -> None:
|
| 137 |
+
super().__init__(message or code)
|
| 138 |
+
self.code = code
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class EventLog:
|
| 142 |
+
"""SQLite-backed append-only event log for one community."""
|
| 143 |
+
|
| 144 |
+
def __init__(
|
| 145 |
+
self,
|
| 146 |
+
db_path: Path,
|
| 147 |
+
community_id: str,
|
| 148 |
+
kp_store: Any = None,
|
| 149 |
+
) -> None:
|
| 150 |
+
self._db_path = db_path
|
| 151 |
+
self._community_id = community_id
|
| 152 |
+
self._kp_store = kp_store
|
| 153 |
+
self._lock = threading.Lock()
|
| 154 |
+
self._subscribers: list[tuple[asyncio.Queue[Event], frozenset[str] | None]] = []
|
| 155 |
+
|
| 156 |
+
self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
| 157 |
+
self._init_schema()
|
| 158 |
+
self._clock = LamportClock(community_id, db_path)
|
| 159 |
+
self._clock._conn = self._conn # share connection
|
| 160 |
+
|
| 161 |
+
# ------------------------------------------------------------------
|
| 162 |
+
# Schema
|
| 163 |
+
# ------------------------------------------------------------------
|
| 164 |
+
|
| 165 |
+
def _init_schema(self) -> None:
|
| 166 |
+
for stmt in _SCHEMA.strip().split(";"):
|
| 167 |
+
stmt = stmt.strip()
|
| 168 |
+
if stmt:
|
| 169 |
+
self._conn.execute(stmt)
|
| 170 |
+
self._conn.commit()
|
| 171 |
+
|
| 172 |
+
# ------------------------------------------------------------------
|
| 173 |
+
# Writing
|
| 174 |
+
# ------------------------------------------------------------------
|
| 175 |
+
|
| 176 |
+
def append_local(
|
| 177 |
+
self,
|
| 178 |
+
event_type: EventType,
|
| 179 |
+
author: str,
|
| 180 |
+
payload: dict[str, Any],
|
| 181 |
+
kp: Any = None,
|
| 182 |
+
) -> Event:
|
| 183 |
+
"""Mint, sign, and persist a new local event atomically."""
|
| 184 |
+
if event_type not in _ALL_EVENT_TYPES:
|
| 185 |
+
raise EventLogError("schema_unknown", f"Unknown event_type: {event_type!r}")
|
| 186 |
+
|
| 187 |
+
with self._lock:
|
| 188 |
+
lamport = self._clock._value + 1
|
| 189 |
+
event_id = new_ulid()
|
| 190 |
+
now = _now_utc()
|
| 191 |
+
|
| 192 |
+
# Build unsigned event first to produce canonical bytes
|
| 193 |
+
event = Event(
|
| 194 |
+
schema_version=1,
|
| 195 |
+
event_id=event_id,
|
| 196 |
+
event_type=event_type,
|
| 197 |
+
community_id=self._community_id,
|
| 198 |
+
author=author,
|
| 199 |
+
lamport=lamport,
|
| 200 |
+
payload=payload,
|
| 201 |
+
issued_at=now,
|
| 202 |
+
signature="",
|
| 203 |
+
)
|
| 204 |
+
sig = _sign(event, kp)
|
| 205 |
+
# Replace with signed version
|
| 206 |
+
import dataclasses
|
| 207 |
+
|
| 208 |
+
event = dataclasses.replace(event, signature=sig)
|
| 209 |
+
|
| 210 |
+
self._clock._value = lamport
|
| 211 |
+
self._conn.execute("BEGIN")
|
| 212 |
+
try:
|
| 213 |
+
self._conn.execute(
|
| 214 |
+
"INSERT INTO events "
|
| 215 |
+
"(event_id,event_type,community_id,author,lamport,payload,issued_at,signature,schema_version,received_at) "
|
| 216 |
+
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
| 217 |
+
(
|
| 218 |
+
event.event_id,
|
| 219 |
+
event.event_type,
|
| 220 |
+
event.community_id,
|
| 221 |
+
event.author,
|
| 222 |
+
event.lamport,
|
| 223 |
+
json.dumps(event.payload, sort_keys=True),
|
| 224 |
+
event.issued_at,
|
| 225 |
+
event.signature,
|
| 226 |
+
event.schema_version,
|
| 227 |
+
now,
|
| 228 |
+
),
|
| 229 |
+
)
|
| 230 |
+
self._clock._save_in_tx(self._conn)
|
| 231 |
+
self._conn.execute("COMMIT")
|
| 232 |
+
except Exception:
|
| 233 |
+
self._conn.execute("ROLLBACK")
|
| 234 |
+
raise
|
| 235 |
+
|
| 236 |
+
self._fanout(event)
|
| 237 |
+
return event
|
| 238 |
+
|
| 239 |
+
def append_received(self, event: Event) -> bool:
|
| 240 |
+
"""Persist a peer event. Returns False for duplicates, True if new."""
|
| 241 |
+
if event.event_type not in _ALL_EVENT_TYPES:
|
| 242 |
+
raise EventLogError("schema_unknown", f"Unknown event_type: {event.event_type!r}")
|
| 243 |
+
|
| 244 |
+
if not _verify(event, self._kp_store):
|
| 245 |
+
raise EventLogError("invalid_signature", f"Bad signature on {event.event_id}")
|
| 246 |
+
|
| 247 |
+
with self._lock:
|
| 248 |
+
# Duplicate check
|
| 249 |
+
dup = self._conn.execute(
|
| 250 |
+
"SELECT 1 FROM events WHERE event_id = ?", (event.event_id,)
|
| 251 |
+
).fetchone()
|
| 252 |
+
if dup:
|
| 253 |
+
return False
|
| 254 |
+
|
| 255 |
+
new_lamport = max(self._clock._value, event.lamport) + 1
|
| 256 |
+
now = _now_utc()
|
| 257 |
+
|
| 258 |
+
self._conn.execute("BEGIN")
|
| 259 |
+
try:
|
| 260 |
+
self._conn.execute(
|
| 261 |
+
"INSERT INTO events "
|
| 262 |
+
"(event_id,event_type,community_id,author,lamport,payload,issued_at,signature,schema_version,received_at) "
|
| 263 |
+
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
| 264 |
+
(
|
| 265 |
+
event.event_id,
|
| 266 |
+
event.event_type,
|
| 267 |
+
event.community_id,
|
| 268 |
+
event.author,
|
| 269 |
+
event.lamport,
|
| 270 |
+
json.dumps(event.payload, sort_keys=True),
|
| 271 |
+
event.issued_at,
|
| 272 |
+
event.signature,
|
| 273 |
+
event.schema_version,
|
| 274 |
+
now,
|
| 275 |
+
),
|
| 276 |
+
)
|
| 277 |
+
self._clock._value = new_lamport
|
| 278 |
+
self._clock._save_in_tx(self._conn)
|
| 279 |
+
self._conn.execute("COMMIT")
|
| 280 |
+
except Exception:
|
| 281 |
+
self._conn.execute("ROLLBACK")
|
| 282 |
+
raise
|
| 283 |
+
|
| 284 |
+
self._fanout(event)
|
| 285 |
+
return True
|
| 286 |
+
|
| 287 |
+
# ------------------------------------------------------------------
|
| 288 |
+
# Reading
|
| 289 |
+
# ------------------------------------------------------------------
|
| 290 |
+
|
| 291 |
+
def get(self, event_id: str) -> Event | None:
|
| 292 |
+
row = self._conn.execute(
|
| 293 |
+
"SELECT event_id,event_type,community_id,author,lamport,payload,issued_at,signature,schema_version,received_at "
|
| 294 |
+
"FROM events WHERE event_id = ?",
|
| 295 |
+
(event_id,),
|
| 296 |
+
).fetchone()
|
| 297 |
+
return _row_to_event(row) if row else None
|
| 298 |
+
|
| 299 |
+
def since(self, lamport: int, limit: int = 1000) -> list[Event]:
|
| 300 |
+
"""Return events with lamport >= given value, ordered by (lamport, event_id)."""
|
| 301 |
+
rows = self._conn.execute(
|
| 302 |
+
"SELECT event_id,event_type,community_id,author,lamport,payload,issued_at,signature,schema_version,received_at "
|
| 303 |
+
"FROM events WHERE community_id = ? AND lamport >= ? "
|
| 304 |
+
"ORDER BY lamport ASC, event_id ASC LIMIT ?",
|
| 305 |
+
(self._community_id, lamport, limit),
|
| 306 |
+
).fetchall()
|
| 307 |
+
return [_row_to_event(r) for r in rows]
|
| 308 |
+
|
| 309 |
+
def head(self) -> int:
|
| 310 |
+
"""Highest Lamport value stored."""
|
| 311 |
+
row = self._conn.execute(
|
| 312 |
+
"SELECT MAX(lamport) FROM events WHERE community_id = ?",
|
| 313 |
+
(self._community_id,),
|
| 314 |
+
).fetchone()
|
| 315 |
+
return row[0] if row and row[0] is not None else 0
|
| 316 |
+
|
| 317 |
+
def by_type(self, event_type: EventType, since_lamport: int = 0) -> list[Event]:
|
| 318 |
+
rows = self._conn.execute(
|
| 319 |
+
"SELECT event_id,event_type,community_id,author,lamport,payload,issued_at,signature,schema_version,received_at "
|
| 320 |
+
"FROM events WHERE community_id = ? AND event_type = ? AND lamport >= ? "
|
| 321 |
+
"ORDER BY lamport ASC, event_id ASC",
|
| 322 |
+
(self._community_id, event_type, since_lamport),
|
| 323 |
+
).fetchall()
|
| 324 |
+
return [_row_to_event(r) for r in rows]
|
| 325 |
+
|
| 326 |
+
def heads_by_type(self) -> dict[str, int]:
|
| 327 |
+
"""Highest lamport per event_type; used by sync."""
|
| 328 |
+
rows = self._conn.execute(
|
| 329 |
+
"SELECT event_type, MAX(lamport) FROM events WHERE community_id = ? GROUP BY event_type",
|
| 330 |
+
(self._community_id,),
|
| 331 |
+
).fetchall()
|
| 332 |
+
return {row[0]: row[1] for row in rows}
|
| 333 |
+
|
| 334 |
+
def replay(
|
| 335 |
+
self,
|
| 336 |
+
*,
|
| 337 |
+
since_lamport: int = 0,
|
| 338 |
+
event_types: list[EventType] | None = None,
|
| 339 |
+
limit: int | None = None,
|
| 340 |
+
) -> list[Event]:
|
| 341 |
+
"""Return events in (lamport, event_id) order, optionally filtered."""
|
| 342 |
+
if event_types:
|
| 343 |
+
placeholders = ",".join("?" for _ in event_types)
|
| 344 |
+
sql = (
|
| 345 |
+
# nosec B608 — placeholders is computed from len(event_types), not user input
|
| 346 |
+
f"SELECT event_id,event_type,community_id,author,lamport,payload,issued_at,signature,schema_version,received_at "
|
| 347 |
+
f"FROM events WHERE community_id = ? AND lamport >= ? AND event_type IN ({placeholders}) "
|
| 348 |
+
f"ORDER BY lamport ASC, event_id ASC"
|
| 349 |
+
)
|
| 350 |
+
params: list[Any] = [self._community_id, since_lamport, *event_types]
|
| 351 |
+
else:
|
| 352 |
+
sql = (
|
| 353 |
+
"SELECT event_id,event_type,community_id,author,lamport,payload,issued_at,signature,schema_version,received_at "
|
| 354 |
+
"FROM events WHERE community_id = ? AND lamport >= ? "
|
| 355 |
+
"ORDER BY lamport ASC, event_id ASC"
|
| 356 |
+
)
|
| 357 |
+
params = [self._community_id, since_lamport]
|
| 358 |
+
|
| 359 |
+
if limit is not None:
|
| 360 |
+
sql += f" LIMIT {int(limit)}"
|
| 361 |
+
|
| 362 |
+
rows = self._conn.execute(sql, params).fetchall()
|
| 363 |
+
return [_row_to_event(r) for r in rows]
|
| 364 |
+
|
| 365 |
+
# ------------------------------------------------------------------
|
| 366 |
+
# Pubsub
|
| 367 |
+
# ------------------------------------------------------------------
|
| 368 |
+
|
| 369 |
+
def subscribe(
|
| 370 |
+
self,
|
| 371 |
+
event_types: list[EventType] | None = None,
|
| 372 |
+
) -> AsyncIterator[Event]:
|
| 373 |
+
"""Return an async iterator that yields matching events as they arrive."""
|
| 374 |
+
q: asyncio.Queue[Event] = asyncio.Queue()
|
| 375 |
+
ft: frozenset[str] | None = frozenset(event_types) if event_types else None
|
| 376 |
+
self._subscribers.append((q, ft))
|
| 377 |
+
|
| 378 |
+
async def _iter() -> AsyncIterator[Event]:
|
| 379 |
+
try:
|
| 380 |
+
while True:
|
| 381 |
+
event = await q.get()
|
| 382 |
+
yield event
|
| 383 |
+
except GeneratorExit:
|
| 384 |
+
pass
|
| 385 |
+
finally:
|
| 386 |
+
try:
|
| 387 |
+
self._subscribers.remove((q, ft))
|
| 388 |
+
except ValueError:
|
| 389 |
+
pass
|
| 390 |
+
|
| 391 |
+
return _iter() # type: ignore[return-value]
|
| 392 |
+
|
| 393 |
+
# ------------------------------------------------------------------
|
| 394 |
+
# Internal
|
| 395 |
+
# ------------------------------------------------------------------
|
| 396 |
+
|
| 397 |
+
def close(self) -> None:
|
| 398 |
+
"""Close the underlying SQLite connection."""
|
| 399 |
+
try:
|
| 400 |
+
self._conn.close()
|
| 401 |
+
except Exception:
|
| 402 |
+
pass
|
| 403 |
+
|
| 404 |
+
def _fanout(self, event: Event) -> None:
|
| 405 |
+
"""Push event to all in-process subscribers (best-effort)."""
|
| 406 |
+
for q, filter_types in list(self._subscribers):
|
| 407 |
+
if filter_types is None or event.event_type in filter_types:
|
| 408 |
+
try:
|
| 409 |
+
q.put_nowait(event)
|
| 410 |
+
except asyncio.QueueFull:
|
| 411 |
+
pass
|
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import TYPE_CHECKING, Protocol
|
| 4 |
+
|
| 5 |
+
from .types import Event, EventType
|
| 6 |
+
|
| 7 |
+
if TYPE_CHECKING:
|
| 8 |
+
from .log import EventLog
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class MaterialisedView(Protocol):
|
| 12 |
+
"""Protocol that all consuming-module views must satisfy."""
|
| 13 |
+
|
| 14 |
+
def reset(self) -> None:
|
| 15 |
+
"""Clear all state (called before a full replay)."""
|
| 16 |
+
...
|
| 17 |
+
|
| 18 |
+
def apply(self, event: Event) -> None:
|
| 19 |
+
"""Incorporate a single event into the view's state."""
|
| 20 |
+
...
|
| 21 |
+
|
| 22 |
+
def snapshot_state(self) -> dict:
|
| 23 |
+
"""Return a JSON-serialisable representation of current state."""
|
| 24 |
+
...
|
| 25 |
+
|
| 26 |
+
def restore_state(self, state: dict) -> None:
|
| 27 |
+
"""Reinstate state produced by snapshot_state()."""
|
| 28 |
+
...
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class ReplayEngine:
|
| 32 |
+
"""Routes events to registered materialised views."""
|
| 33 |
+
|
| 34 |
+
def __init__(self, log: EventLog) -> None:
|
| 35 |
+
self.log = log
|
| 36 |
+
# view_name -> (view, set of event_types it cares about or None for all)
|
| 37 |
+
self._views: dict[str, tuple[MaterialisedView, frozenset[str] | None]] = {}
|
| 38 |
+
|
| 39 |
+
# ------------------------------------------------------------------
|
| 40 |
+
# Registration
|
| 41 |
+
# ------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
def register(
|
| 44 |
+
self,
|
| 45 |
+
name: str,
|
| 46 |
+
view: MaterialisedView,
|
| 47 |
+
event_types: list[EventType] | None = None,
|
| 48 |
+
) -> None:
|
| 49 |
+
"""Register *view* under *name*. Pass ``event_types=None`` for all types."""
|
| 50 |
+
ft: frozenset[str] | None = frozenset(event_types) if event_types else None
|
| 51 |
+
self._views[name] = (view, ft)
|
| 52 |
+
|
| 53 |
+
# Alias used in task spec
|
| 54 |
+
def register_view(
|
| 55 |
+
self,
|
| 56 |
+
view: MaterialisedView,
|
| 57 |
+
event_types: list[EventType],
|
| 58 |
+
) -> None:
|
| 59 |
+
name = type(view).__name__
|
| 60 |
+
self.register(name, view, event_types)
|
| 61 |
+
|
| 62 |
+
# ------------------------------------------------------------------
|
| 63 |
+
# Replay
|
| 64 |
+
# ------------------------------------------------------------------
|
| 65 |
+
|
| 66 |
+
def rebuild(self, view_name: str, from_lamport: int = 0) -> None:
|
| 67 |
+
"""Reset the named view and replay all relevant events from *from_lamport*."""
|
| 68 |
+
view, ft = self._views[view_name]
|
| 69 |
+
view.reset()
|
| 70 |
+
event_types = list(ft) if ft is not None else None
|
| 71 |
+
for event in self.log.replay(since_lamport=from_lamport, event_types=event_types): # type: ignore[arg-type]
|
| 72 |
+
view.apply(event)
|
| 73 |
+
|
| 74 |
+
def rebuild_all(self, from_lamport: int = 0) -> None:
|
| 75 |
+
"""Reset and replay all registered views."""
|
| 76 |
+
for name in list(self._views):
|
| 77 |
+
self.rebuild(name, from_lamport)
|
| 78 |
+
|
| 79 |
+
# Alias used in task spec
|
| 80 |
+
def replay_all(self) -> None:
|
| 81 |
+
self.rebuild_all(from_lamport=0)
|
| 82 |
+
|
| 83 |
+
def replay_since(self, lamport: int) -> None:
|
| 84 |
+
"""Replay (without reset) all views for events at lamport >= *lamport*."""
|
| 85 |
+
# Collect all event types across views
|
| 86 |
+
for _name, (view, ft) in self._views.items():
|
| 87 |
+
event_types = list(ft) if ft is not None else None
|
| 88 |
+
for event in self.log.replay(since_lamport=lamport, event_types=event_types): # type: ignore[arg-type]
|
| 89 |
+
view.apply(event)
|
| 90 |
+
|
| 91 |
+
# ------------------------------------------------------------------
|
| 92 |
+
# Live fanout
|
| 93 |
+
# ------------------------------------------------------------------
|
| 94 |
+
|
| 95 |
+
def _on_event(self, event: Event) -> None:
|
| 96 |
+
"""Route a newly-arrived event to all subscribed views."""
|
| 97 |
+
for _name, (view, ft) in self._views.items():
|
| 98 |
+
if ft is None or event.event_type in ft:
|
| 99 |
+
view.apply(event)
|
| 100 |
+
|
| 101 |
+
# Alias used in spec
|
| 102 |
+
on_event = _on_event
|
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import TYPE_CHECKING, Any
|
| 10 |
+
|
| 11 |
+
if TYPE_CHECKING:
|
| 12 |
+
from .log import EventLog
|
| 13 |
+
from .replay import ReplayEngine
|
| 14 |
+
|
| 15 |
+
_SNAPSHOT_LAG_LAMPORT = 1000
|
| 16 |
+
_SCHEMA_VERSION = 1
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _now_utc() -> str:
|
| 20 |
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f") + "Z"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _sign_snapshot(data: bytes, kp: Any) -> str:
|
| 24 |
+
if kp is None:
|
| 25 |
+
return ""
|
| 26 |
+
if hasattr(kp, "sign"):
|
| 27 |
+
sig_bytes: bytes = kp.sign(data)
|
| 28 |
+
else:
|
| 29 |
+
import hashlib
|
| 30 |
+
import hmac
|
| 31 |
+
|
| 32 |
+
sig_bytes = hmac.new(kp, data, hashlib.sha256).digest()
|
| 33 |
+
return "ed25519:" + base64.urlsafe_b64encode(sig_bytes).rstrip(b"=").decode()
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _verify_snapshot(snap: "Snapshot", kp_store: Any) -> bool:
|
| 37 |
+
if kp_store is None or not snap.signature:
|
| 38 |
+
return True
|
| 39 |
+
raw = _canonical_snap_bytes(snap)
|
| 40 |
+
if hasattr(kp_store, "verify"):
|
| 41 |
+
try:
|
| 42 |
+
prefix = "ed25519:"
|
| 43 |
+
b64 = snap.signature[len(prefix) :] if snap.signature.startswith(prefix) else snap.signature
|
| 44 |
+
padding = 4 - len(b64) % 4
|
| 45 |
+
if padding != 4:
|
| 46 |
+
b64 += "=" * padding
|
| 47 |
+
sig_bytes = base64.urlsafe_b64decode(b64)
|
| 48 |
+
return kp_store.verify(snap.author, raw, sig_bytes)
|
| 49 |
+
except Exception:
|
| 50 |
+
return False
|
| 51 |
+
return True
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _canonical_snap_bytes(snap: "Snapshot") -> bytes:
|
| 55 |
+
obj = {
|
| 56 |
+
"schema_version": snap.schema_version,
|
| 57 |
+
"community_id": snap.community_id,
|
| 58 |
+
"at_lamport": snap.at_lamport,
|
| 59 |
+
"views": snap.views,
|
| 60 |
+
"issued_at": snap.issued_at,
|
| 61 |
+
"author": snap.author,
|
| 62 |
+
}
|
| 63 |
+
return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
@dataclass(frozen=True)
|
| 67 |
+
class Snapshot:
|
| 68 |
+
schema_version: int
|
| 69 |
+
community_id: str
|
| 70 |
+
at_lamport: int
|
| 71 |
+
views: dict[str, dict] # view_name -> state dict
|
| 72 |
+
issued_at: str
|
| 73 |
+
author: str
|
| 74 |
+
signature: str
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class SnapshotStore:
|
| 78 |
+
"""Stores snapshots as JSON files under *dir_path*/<community_id>/snapshots/."""
|
| 79 |
+
|
| 80 |
+
def __init__(self, dir_path: Path, community_id: str) -> None:
|
| 81 |
+
self._dir = dir_path / community_id / "snapshots"
|
| 82 |
+
self._dir.mkdir(parents=True, exist_ok=True)
|
| 83 |
+
|
| 84 |
+
def _path_for(self, at_lamport: int) -> Path:
|
| 85 |
+
return self._dir / f"{at_lamport:020d}.json"
|
| 86 |
+
|
| 87 |
+
def write(self, snap: Snapshot) -> None:
|
| 88 |
+
"""Write snapshot atomically."""
|
| 89 |
+
target = self._path_for(snap.at_lamport)
|
| 90 |
+
tmp = target.with_suffix(".tmp")
|
| 91 |
+
payload = {
|
| 92 |
+
"schema_version": snap.schema_version,
|
| 93 |
+
"community_id": snap.community_id,
|
| 94 |
+
"at_lamport": snap.at_lamport,
|
| 95 |
+
"views": snap.views,
|
| 96 |
+
"issued_at": snap.issued_at,
|
| 97 |
+
"author": snap.author,
|
| 98 |
+
"signature": snap.signature,
|
| 99 |
+
}
|
| 100 |
+
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
| 101 |
+
os.replace(tmp, target)
|
| 102 |
+
|
| 103 |
+
def latest(self) -> Snapshot | None:
|
| 104 |
+
lamps = self.list()
|
| 105 |
+
if not lamps:
|
| 106 |
+
return None
|
| 107 |
+
return self._load(lamps[-1])
|
| 108 |
+
|
| 109 |
+
def list(self) -> list[int]:
|
| 110 |
+
"""Return lamport values of all snapshots on disk, ascending."""
|
| 111 |
+
values = []
|
| 112 |
+
for p in sorted(self._dir.glob("*.json")):
|
| 113 |
+
try:
|
| 114 |
+
values.append(int(p.stem))
|
| 115 |
+
except ValueError:
|
| 116 |
+
pass
|
| 117 |
+
return values
|
| 118 |
+
|
| 119 |
+
def prune(self, keep_last_n: int = 7) -> None:
|
| 120 |
+
lamps = self.list()
|
| 121 |
+
to_delete = lamps[:-keep_last_n] if len(lamps) > keep_last_n else []
|
| 122 |
+
for lamp in to_delete:
|
| 123 |
+
self._path_for(lamp).unlink(missing_ok=True)
|
| 124 |
+
|
| 125 |
+
def _load(self, at_lamport: int) -> Snapshot:
|
| 126 |
+
data = json.loads(self._path_for(at_lamport).read_text(encoding="utf-8"))
|
| 127 |
+
return Snapshot(
|
| 128 |
+
schema_version=data["schema_version"],
|
| 129 |
+
community_id=data["community_id"],
|
| 130 |
+
at_lamport=data["at_lamport"],
|
| 131 |
+
views=data["views"],
|
| 132 |
+
issued_at=data["issued_at"],
|
| 133 |
+
author=data["author"],
|
| 134 |
+
signature=data["signature"],
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def build_snapshot(
|
| 139 |
+
log: EventLog,
|
| 140 |
+
engine: ReplayEngine,
|
| 141 |
+
author: str,
|
| 142 |
+
kp: Any = None,
|
| 143 |
+
at_lamport: int | None = None,
|
| 144 |
+
) -> Snapshot:
|
| 145 |
+
"""Build a signed snapshot of all view states up to *at_lamport*.
|
| 146 |
+
|
| 147 |
+
If *at_lamport* is None, uses ``head - SNAPSHOT_LAG_LAMPORT``.
|
| 148 |
+
"""
|
| 149 |
+
head = log.head()
|
| 150 |
+
if at_lamport is None:
|
| 151 |
+
at_lamport = max(0, head - _SNAPSHOT_LAG_LAMPORT)
|
| 152 |
+
|
| 153 |
+
# Rebuild all views up to at_lamport
|
| 154 |
+
for name, (view, ft) in engine._views.items():
|
| 155 |
+
view.reset()
|
| 156 |
+
event_types = list(ft) if ft is not None else None
|
| 157 |
+
for event in log.replay(since_lamport=0, event_types=event_types): # type: ignore[arg-type]
|
| 158 |
+
if event.lamport > at_lamport:
|
| 159 |
+
break
|
| 160 |
+
view.apply(event)
|
| 161 |
+
|
| 162 |
+
views_state: dict[str, dict] = {}
|
| 163 |
+
for name, (view, _ft) in engine._views.items():
|
| 164 |
+
views_state[name] = view.snapshot_state()
|
| 165 |
+
|
| 166 |
+
now = _now_utc()
|
| 167 |
+
snap_unsigned = Snapshot(
|
| 168 |
+
schema_version=_SCHEMA_VERSION,
|
| 169 |
+
community_id=log._community_id,
|
| 170 |
+
at_lamport=at_lamport,
|
| 171 |
+
views=views_state,
|
| 172 |
+
issued_at=now,
|
| 173 |
+
author=author,
|
| 174 |
+
signature="",
|
| 175 |
+
)
|
| 176 |
+
sig = _sign_snapshot(_canonical_snap_bytes(snap_unsigned), kp)
|
| 177 |
+
import dataclasses
|
| 178 |
+
|
| 179 |
+
return dataclasses.replace(snap_unsigned, signature=sig)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def restore_from_snapshot(
|
| 183 |
+
snap: Snapshot,
|
| 184 |
+
engine: ReplayEngine,
|
| 185 |
+
log: EventLog,
|
| 186 |
+
kp_store: Any = None,
|
| 187 |
+
) -> None:
|
| 188 |
+
"""Restore view states from *snap*, then replay any newer events."""
|
| 189 |
+
if not _verify_snapshot(snap, kp_store):
|
| 190 |
+
raise ValueError("Snapshot signature verification failed")
|
| 191 |
+
|
| 192 |
+
for name, state in snap.views.items():
|
| 193 |
+
if name in engine._views:
|
| 194 |
+
engine._views[name][0].restore_state(state)
|
| 195 |
+
|
| 196 |
+
# Replay events that arrived after the snapshot
|
| 197 |
+
for event in log.replay(since_lamport=snap.at_lamport + 1):
|
| 198 |
+
engine._on_event(event)
|
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import TYPE_CHECKING, Any
|
| 7 |
+
|
| 8 |
+
from .types import Event
|
| 9 |
+
|
| 10 |
+
if TYPE_CHECKING:
|
| 11 |
+
from .log import EventLog
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class HeadsReport:
|
| 16 |
+
community_id: str
|
| 17 |
+
node_id: str
|
| 18 |
+
head: int
|
| 19 |
+
heads_by_type: dict[str, int]
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
@dataclass(frozen=True)
|
| 23 |
+
class SyncResult:
|
| 24 |
+
sent_count: int
|
| 25 |
+
received_count: int
|
| 26 |
+
duration_ms: int
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _event_to_dict(event: Event) -> dict[str, Any]:
|
| 30 |
+
return {
|
| 31 |
+
"schema_version": event.schema_version,
|
| 32 |
+
"event_id": event.event_id,
|
| 33 |
+
"event_type": event.event_type,
|
| 34 |
+
"community_id": event.community_id,
|
| 35 |
+
"author": event.author,
|
| 36 |
+
"lamport": event.lamport,
|
| 37 |
+
"payload": event.payload,
|
| 38 |
+
"issued_at": event.issued_at,
|
| 39 |
+
"signature": event.signature,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _dict_to_event(d: dict[str, Any]) -> Event:
|
| 44 |
+
return Event(
|
| 45 |
+
schema_version=d.get("schema_version", 1),
|
| 46 |
+
event_id=d["event_id"],
|
| 47 |
+
event_type=d["event_type"], # type: ignore[arg-type]
|
| 48 |
+
community_id=d["community_id"],
|
| 49 |
+
author=d["author"],
|
| 50 |
+
lamport=d["lamport"],
|
| 51 |
+
payload=d.get("payload", {}),
|
| 52 |
+
issued_at=d["issued_at"],
|
| 53 |
+
signature=d.get("signature", ""),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class SyncClient:
|
| 58 |
+
"""Pull/push gossip sync against a single peer."""
|
| 59 |
+
|
| 60 |
+
def __init__(self, log: EventLog, http_client: Any = None) -> None:
|
| 61 |
+
self._log = log
|
| 62 |
+
self._http = http_client
|
| 63 |
+
|
| 64 |
+
async def sync_with(self, peer_url: str, community_id: str) -> SyncResult:
|
| 65 |
+
"""Gossip sync with peer:
|
| 66 |
+
1. GET /sync/v1/heads → peer HeadsReport
|
| 67 |
+
2. POST /sync/v1/events → push events peer is missing
|
| 68 |
+
3. Receive events we are missing and apply them.
|
| 69 |
+
"""
|
| 70 |
+
start = int(time.time() * 1000)
|
| 71 |
+
|
| 72 |
+
if self._http is None:
|
| 73 |
+
# No transport available; return a no-op result
|
| 74 |
+
return SyncResult(sent_count=0, received_count=0, duration_ms=0)
|
| 75 |
+
|
| 76 |
+
# Step 1: fetch peer heads
|
| 77 |
+
resp = await self._http.get(f"{peer_url.rstrip('/')}/sync/v1/heads")
|
| 78 |
+
peer_heads: dict[str, Any] = resp if isinstance(resp, dict) else await resp.json()
|
| 79 |
+
peer_head: int = peer_heads.get("head", 0)
|
| 80 |
+
|
| 81 |
+
# Step 2: send events peer doesn't have
|
| 82 |
+
local_head = self._log.head()
|
| 83 |
+
our_missing: list[dict[str, Any]] = []
|
| 84 |
+
if local_head > peer_head:
|
| 85 |
+
events_to_send = self._log.since(peer_head + 1)
|
| 86 |
+
our_missing = [_event_to_dict(e) for e in events_to_send]
|
| 87 |
+
|
| 88 |
+
# Step 3: POST our missing and receive theirs
|
| 89 |
+
body = json.dumps(
|
| 90 |
+
{
|
| 91 |
+
"community_id": community_id,
|
| 92 |
+
"events": our_missing,
|
| 93 |
+
"our_head": local_head,
|
| 94 |
+
}
|
| 95 |
+
)
|
| 96 |
+
resp2 = await self._http.post(
|
| 97 |
+
f"{peer_url.rstrip('/')}/sync/v1/events",
|
| 98 |
+
data=body,
|
| 99 |
+
headers={"Content-Type": "application/json"},
|
| 100 |
+
)
|
| 101 |
+
result_data: dict[str, Any] = resp2 if isinstance(resp2, dict) else await resp2.json()
|
| 102 |
+
|
| 103 |
+
# Apply events the peer sent back
|
| 104 |
+
received_events: list[dict[str, Any]] = result_data.get("events", [])
|
| 105 |
+
received_count = 0
|
| 106 |
+
for ed in received_events:
|
| 107 |
+
try:
|
| 108 |
+
event = _dict_to_event(ed)
|
| 109 |
+
if self._log.append_received(event):
|
| 110 |
+
received_count += 1
|
| 111 |
+
except Exception:
|
| 112 |
+
pass
|
| 113 |
+
|
| 114 |
+
duration_ms = int(time.time() * 1000) - start
|
| 115 |
+
return SyncResult(
|
| 116 |
+
sent_count=len(our_missing),
|
| 117 |
+
received_count=received_count,
|
| 118 |
+
duration_ms=duration_ms,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
class SyncServer:
|
| 123 |
+
"""Exposes /sync/v1/heads and /sync/v1/events handler logic."""
|
| 124 |
+
|
| 125 |
+
def __init__(self, log: EventLog) -> None:
|
| 126 |
+
self._log = log
|
| 127 |
+
|
| 128 |
+
def heads(self) -> HeadsReport:
|
| 129 |
+
return HeadsReport(
|
| 130 |
+
community_id=self._log._community_id,
|
| 131 |
+
node_id="", # caller should inject node_id if needed
|
| 132 |
+
head=self._log.head(),
|
| 133 |
+
heads_by_type=self._log.heads_by_type(),
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
async def serve_heads(self) -> dict[str, Any]:
|
| 137 |
+
report = self.heads()
|
| 138 |
+
return {
|
| 139 |
+
"community_id": report.community_id,
|
| 140 |
+
"head": report.head,
|
| 141 |
+
"heads_by_type": report.heads_by_type,
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
async def serve_events(self, body: dict[str, Any]) -> dict[str, Any]:
|
| 145 |
+
"""Accept events from a peer and return events the peer is missing.
|
| 146 |
+
|
| 147 |
+
Expected body keys: ``community_id``, ``events``, ``our_head`` (peer's head).
|
| 148 |
+
"""
|
| 149 |
+
incoming: list[dict[str, Any]] = body.get("events", [])
|
| 150 |
+
peer_head: int = body.get("our_head", 0)
|
| 151 |
+
|
| 152 |
+
accepted = 0
|
| 153 |
+
rejected = 0
|
| 154 |
+
rejected_reasons: list[dict[str, str]] = []
|
| 155 |
+
|
| 156 |
+
for ed in incoming:
|
| 157 |
+
try:
|
| 158 |
+
event = _dict_to_event(ed)
|
| 159 |
+
if self._log.append_received(event):
|
| 160 |
+
accepted += 1
|
| 161 |
+
except Exception as exc:
|
| 162 |
+
rejected += 1
|
| 163 |
+
rejected_reasons.append(
|
| 164 |
+
{
|
| 165 |
+
"event_id": ed.get("event_id", ""),
|
| 166 |
+
"reason": str(exc),
|
| 167 |
+
}
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
# Events the requesting peer is missing
|
| 171 |
+
missing_for_peer = [
|
| 172 |
+
_event_to_dict(e) for e in self._log.since(peer_head + 1)
|
| 173 |
+
]
|
| 174 |
+
|
| 175 |
+
return {
|
| 176 |
+
"accepted": accepted,
|
| 177 |
+
"rejected": rejected,
|
| 178 |
+
"rejected_reasons": rejected_reasons,
|
| 179 |
+
"new_head": self._log.head(),
|
| 180 |
+
"events": missing_for_peer,
|
| 181 |
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import secrets
|
| 5 |
+
import time
|
| 6 |
+
from dataclasses import dataclass
|
| 7 |
+
from typing import Any, Literal
|
| 8 |
+
|
| 9 |
+
EventType = Literal[
|
| 10 |
+
"community.created",
|
| 11 |
+
"community.member.joined",
|
| 12 |
+
"community.member.left",
|
| 13 |
+
"community.member.invited",
|
| 14 |
+
"community.member.removed",
|
| 15 |
+
"community.member.revoked",
|
| 16 |
+
"community.member.promoted",
|
| 17 |
+
"community.member.demoted",
|
| 18 |
+
"community.policy.updated",
|
| 19 |
+
"node.manifest.updated",
|
| 20 |
+
"market.post.created",
|
| 21 |
+
"market.post.updated",
|
| 22 |
+
"market.post.expired",
|
| 23 |
+
"chat.message.sent",
|
| 24 |
+
"chat.message.delivered",
|
| 25 |
+
"chat.message.read",
|
| 26 |
+
"rag.document.ingested",
|
| 27 |
+
"file.advertised",
|
| 28 |
+
"file.cid.advertised",
|
| 29 |
+
"file.cid.unpinned",
|
| 30 |
+
"federation.peer.added",
|
| 31 |
+
"federation.peer.removed",
|
| 32 |
+
]
|
| 33 |
+
|
| 34 |
+
_ALL_EVENT_TYPES: frozenset[str] = frozenset(EventType.__args__) # type: ignore[attr-defined]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def new_ulid() -> str:
|
| 38 |
+
"""Generate a sortable unique ID (ULID-compatible 26-char string)."""
|
| 39 |
+
ts = int(time.time() * 1000)
|
| 40 |
+
ts_bytes = ts.to_bytes(10, "big")
|
| 41 |
+
rand_bytes = secrets.token_bytes(10)
|
| 42 |
+
raw = ts_bytes + rand_bytes # 20 bytes
|
| 43 |
+
encoded = base64.b32encode(raw).decode("ascii") # 32 chars
|
| 44 |
+
return encoded[:26]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass(frozen=True)
|
| 48 |
+
class Event:
|
| 49 |
+
schema_version: int # always 1
|
| 50 |
+
event_id: str # ULID
|
| 51 |
+
event_type: EventType
|
| 52 |
+
community_id: str
|
| 53 |
+
author: str # full node_id
|
| 54 |
+
lamport: int
|
| 55 |
+
payload: dict[str, Any]
|
| 56 |
+
issued_at: str # RFC 3339 UTC
|
| 57 |
+
signature: str # "ed25519:<b64url>" or ""
|
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
"""hearthnet.identity — M01 Identity module.
|
| 4 |
+
|
| 5 |
+
Provides Ed25519 key management, canonical JSON, signing/verification,
|
| 6 |
+
and node/community manifests.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from hearthnet.identity.keys import (
|
| 10 |
+
IdentityError,
|
| 11 |
+
KeyPair,
|
| 12 |
+
canonical_json,
|
| 13 |
+
full_node_id,
|
| 14 |
+
generate,
|
| 15 |
+
load,
|
| 16 |
+
load_or_generate,
|
| 17 |
+
parse_node_id,
|
| 18 |
+
save,
|
| 19 |
+
short_node_id,
|
| 20 |
+
sign_payload,
|
| 21 |
+
verify_payload,
|
| 22 |
+
verify_payload_with_node_id,
|
| 23 |
+
)
|
| 24 |
+
from hearthnet.identity.manifest import (
|
| 25 |
+
CommunityManifest,
|
| 26 |
+
ManifestError,
|
| 27 |
+
NodeManifest,
|
| 28 |
+
build_community_manifest,
|
| 29 |
+
build_node_manifest,
|
| 30 |
+
verify_community_manifest,
|
| 31 |
+
verify_node_manifest,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
__all__ = [
|
| 35 |
+
# keys
|
| 36 |
+
"KeyPair",
|
| 37 |
+
"IdentityError",
|
| 38 |
+
"generate",
|
| 39 |
+
"load",
|
| 40 |
+
"load_or_generate",
|
| 41 |
+
"save",
|
| 42 |
+
"canonical_json",
|
| 43 |
+
"sign_payload",
|
| 44 |
+
"verify_payload",
|
| 45 |
+
"verify_payload_with_node_id",
|
| 46 |
+
"short_node_id",
|
| 47 |
+
"full_node_id",
|
| 48 |
+
"parse_node_id",
|
| 49 |
+
# manifest
|
| 50 |
+
"ManifestError",
|
| 51 |
+
"NodeManifest",
|
| 52 |
+
"CommunityManifest",
|
| 53 |
+
"build_node_manifest",
|
| 54 |
+
"verify_node_manifest",
|
| 55 |
+
"build_community_manifest",
|
| 56 |
+
"verify_community_manifest",
|
| 57 |
+
]
|
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
import stat
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
import nacl.exceptions
|
| 13 |
+
import nacl.signing
|
| 14 |
+
|
| 15 |
+
_NACL_AVAILABLE = True
|
| 16 |
+
except ImportError: # pragma: no cover
|
| 17 |
+
_NACL_AVAILABLE = False
|
| 18 |
+
|
| 19 |
+
# ---------------------------------------------------------------------------
|
| 20 |
+
# Types
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
|
| 23 |
+
NodeID = str # "ed25519:XXXX-XXXX-XXXX-XXXX" (short) or "ed25519:<b64url>" (full)
|
| 24 |
+
Signature = str # "ed25519:<b64url>"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class IdentityError(Exception):
|
| 28 |
+
"""Raised for all identity-layer failures."""
|
| 29 |
+
|
| 30 |
+
def __init__(self, code: str, reason: str = "") -> None:
|
| 31 |
+
super().__init__(reason or code)
|
| 32 |
+
self.code = code
|
| 33 |
+
self.reason = reason
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
@dataclass(frozen=True)
|
| 37 |
+
class KeyPair:
|
| 38 |
+
signing_key: Any # nacl.signing.SigningKey
|
| 39 |
+
verify_key: Any # nacl.signing.VerifyKey
|
| 40 |
+
node_id_short: str
|
| 41 |
+
node_id_full: str
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
# ID helpers
|
| 46 |
+
# ---------------------------------------------------------------------------
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def short_node_id(verify_key_bytes: bytes) -> str:
|
| 50 |
+
"""First 8 bytes base32, grouped in 4-char segments: 'ed25519:XXXX-XXXX-XXXX-XXXX'."""
|
| 51 |
+
raw = base64.b32encode(verify_key_bytes[:8]).decode("ascii")
|
| 52 |
+
grouped = "-".join(raw[i : i + 4] for i in range(0, len(raw), 4))
|
| 53 |
+
return f"ed25519:{grouped}"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def full_node_id(verify_key_bytes: bytes) -> str:
|
| 57 |
+
"""All 32 bytes base64url no-pad: 'ed25519:<b64>'."""
|
| 58 |
+
b64 = base64.urlsafe_b64encode(verify_key_bytes).rstrip(b"=").decode("ascii")
|
| 59 |
+
return f"ed25519:{b64}"
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def parse_node_id(node_id: str) -> bytes:
|
| 63 |
+
"""Decode a full node_id to 32 bytes. Short form raises ValueError."""
|
| 64 |
+
import re
|
| 65 |
+
if not node_id.startswith("ed25519:"):
|
| 66 |
+
raise ValueError(f"node_id must start with 'ed25519:': {node_id!r}")
|
| 67 |
+
payload = node_id[len("ed25519:"):]
|
| 68 |
+
# Short form is b32-with-dashes: groups of [A-Z2-7=]{1,4} separated by '-'
|
| 69 |
+
# e.g. "SQ2J-OH7E-LCMU-Y===" — always shorter than 30 chars and matches this pattern.
|
| 70 |
+
# Full form is 43-char base64url (no '=' padding).
|
| 71 |
+
if re.fullmatch(r"[A-Z2-7=]{1,4}(-[A-Z2-7=]{1,4}){1,}", payload):
|
| 72 |
+
raise ValueError(
|
| 73 |
+
"Short node IDs cannot be decoded to raw bytes; use full form."
|
| 74 |
+
)
|
| 75 |
+
# Add padding back for base64url decoding
|
| 76 |
+
padded = payload + "=" * (4 - len(payload) % 4 if len(payload) % 4 != 0 else 0)
|
| 77 |
+
raw = base64.urlsafe_b64decode(padded)
|
| 78 |
+
if len(raw) != 32:
|
| 79 |
+
raise ValueError(f"Expected 32 bytes, got {len(raw)}")
|
| 80 |
+
return raw
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------------------------------------------------------------------------
|
| 84 |
+
# Canonical JSON
|
| 85 |
+
# ---------------------------------------------------------------------------
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def canonical_json(obj: Any) -> bytes:
|
| 89 |
+
"""Canonical JSON: sorted keys, no whitespace, numbers stripped of trailing zeros, UTF-8."""
|
| 90 |
+
serialised = json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
| 91 |
+
# Strip trailing zeros from numbers: 1.0 -> 1, 1.10 -> 1.1
|
| 92 |
+
# We post-process the JSON string carefully without breaking string contents.
|
| 93 |
+
result = _strip_trailing_zeros(serialised)
|
| 94 |
+
return result.encode("utf-8")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _strip_trailing_zeros(s: str) -> str:
|
| 98 |
+
"""Remove trailing zeros from JSON numbers without touching string values."""
|
| 99 |
+
import re
|
| 100 |
+
|
| 101 |
+
# Match JSON numbers (integers, floats, exponent forms) that appear outside strings
|
| 102 |
+
# We parse character-by-character to skip string literals.
|
| 103 |
+
out: list[str] = []
|
| 104 |
+
i = 0
|
| 105 |
+
n = len(s)
|
| 106 |
+
while i < n:
|
| 107 |
+
c = s[i]
|
| 108 |
+
if c == '"':
|
| 109 |
+
# Scan to end of string, respecting escapes
|
| 110 |
+
out.append(c)
|
| 111 |
+
i += 1
|
| 112 |
+
while i < n:
|
| 113 |
+
ch = s[i]
|
| 114 |
+
out.append(ch)
|
| 115 |
+
if ch == "\\":
|
| 116 |
+
i += 1
|
| 117 |
+
if i < n:
|
| 118 |
+
out.append(s[i])
|
| 119 |
+
elif ch == '"':
|
| 120 |
+
i += 1
|
| 121 |
+
break
|
| 122 |
+
i += 1
|
| 123 |
+
else:
|
| 124 |
+
# Look for a number token
|
| 125 |
+
m = re.match(r"-?(?:0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?", s[i:])
|
| 126 |
+
if m and (m.group(1) or m.group(2)):
|
| 127 |
+
num_str = m.group(0)
|
| 128 |
+
# Parse and reformat
|
| 129 |
+
try:
|
| 130 |
+
val = float(num_str)
|
| 131 |
+
# If it represents an integer value, emit as integer
|
| 132 |
+
if val == int(val) and "e" not in num_str.lower():
|
| 133 |
+
out.append(str(int(val)))
|
| 134 |
+
else:
|
| 135 |
+
# Strip trailing zeros from decimal part
|
| 136 |
+
formatted = f"{val:g}"
|
| 137 |
+
out.append(formatted)
|
| 138 |
+
except (ValueError, OverflowError):
|
| 139 |
+
out.append(num_str)
|
| 140 |
+
i += len(num_str)
|
| 141 |
+
else:
|
| 142 |
+
out.append(c)
|
| 143 |
+
i += 1
|
| 144 |
+
return "".join(out)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ---------------------------------------------------------------------------
|
| 148 |
+
# Signing / Verification
|
| 149 |
+
# ---------------------------------------------------------------------------
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def sign_payload(payload: dict, kp: KeyPair) -> dict:
|
| 153 |
+
"""Return a copy of payload with 'signature' field added (signs over payload without signature)."""
|
| 154 |
+
if not _NACL_AVAILABLE:
|
| 155 |
+
raise IdentityError("keys_invalid", reason="PyNaCl not installed")
|
| 156 |
+
unsigned = {k: v for k, v in payload.items() if k != "signature"}
|
| 157 |
+
raw = canonical_json(unsigned)
|
| 158 |
+
try:
|
| 159 |
+
signed = kp.signing_key.sign(raw)
|
| 160 |
+
sig_bytes = signed.signature
|
| 161 |
+
except Exception as exc:
|
| 162 |
+
raise IdentityError("sign_failed", reason=str(exc)) from exc
|
| 163 |
+
sig_b64 = base64.urlsafe_b64encode(sig_bytes).rstrip(b"=").decode("ascii")
|
| 164 |
+
result = dict(unsigned)
|
| 165 |
+
result["signature"] = f"ed25519:{sig_b64}"
|
| 166 |
+
return result
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def verify_payload(payload: dict, vk: Any) -> bool: # vk: nacl.signing.VerifyKey
|
| 170 |
+
"""Verify the 'signature' field of payload against vk. Returns True or raises IdentityError."""
|
| 171 |
+
if not _NACL_AVAILABLE:
|
| 172 |
+
raise IdentityError("keys_invalid", reason="PyNaCl not installed")
|
| 173 |
+
raw_sig = payload.get("signature", "")
|
| 174 |
+
if not raw_sig.startswith("ed25519:"):
|
| 175 |
+
raise IdentityError("verify_failed", reason="signature field missing or malformed")
|
| 176 |
+
sig_b64 = raw_sig[len("ed25519:"):]
|
| 177 |
+
padding = 4 - len(sig_b64) % 4
|
| 178 |
+
if padding != 4:
|
| 179 |
+
sig_b64 += "=" * padding
|
| 180 |
+
try:
|
| 181 |
+
sig_bytes = base64.urlsafe_b64decode(sig_b64)
|
| 182 |
+
except Exception as exc:
|
| 183 |
+
raise IdentityError("verify_failed", reason=f"bad signature encoding: {exc}") from exc
|
| 184 |
+
unsigned = {k: v for k, v in payload.items() if k != "signature"}
|
| 185 |
+
raw = canonical_json(unsigned)
|
| 186 |
+
try:
|
| 187 |
+
vk.verify(raw, sig_bytes)
|
| 188 |
+
except nacl.exceptions.BadSignatureError as exc:
|
| 189 |
+
raise IdentityError("verify_failed", reason="signature verification failed") from exc
|
| 190 |
+
except Exception as exc:
|
| 191 |
+
raise IdentityError("verify_failed", reason=str(exc)) from exc
|
| 192 |
+
return True
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def verify_payload_with_node_id(payload: dict, expected_node_id_full: str) -> bool:
|
| 196 |
+
"""Verify payload signature using the public key encoded in expected_node_id_full."""
|
| 197 |
+
if not _NACL_AVAILABLE:
|
| 198 |
+
raise IdentityError("keys_invalid", reason="PyNaCl not installed")
|
| 199 |
+
try:
|
| 200 |
+
vk_bytes = parse_node_id(expected_node_id_full)
|
| 201 |
+
except ValueError as exc:
|
| 202 |
+
raise IdentityError("bad_node_id", reason=str(exc)) from exc
|
| 203 |
+
try:
|
| 204 |
+
vk = nacl.signing.VerifyKey(vk_bytes)
|
| 205 |
+
except Exception as exc:
|
| 206 |
+
raise IdentityError("keys_invalid", reason=str(exc)) from exc
|
| 207 |
+
return verify_payload(payload, vk)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
# ---------------------------------------------------------------------------
|
| 211 |
+
# Key I/O
|
| 212 |
+
# ---------------------------------------------------------------------------
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def generate() -> KeyPair:
|
| 216 |
+
"""Generate a fresh Ed25519 keypair using os.urandom."""
|
| 217 |
+
if not _NACL_AVAILABLE:
|
| 218 |
+
raise IdentityError("keys_invalid", reason="PyNaCl not installed")
|
| 219 |
+
seed = os.urandom(32)
|
| 220 |
+
sk = nacl.signing.SigningKey(seed)
|
| 221 |
+
vk = sk.verify_key
|
| 222 |
+
vk_bytes = bytes(vk)
|
| 223 |
+
return KeyPair(
|
| 224 |
+
signing_key=sk,
|
| 225 |
+
verify_key=vk,
|
| 226 |
+
node_id_short=short_node_id(vk_bytes),
|
| 227 |
+
node_id_full=full_node_id(vk_bytes),
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def save(kp: KeyPair, keys_dir: Path) -> None:
|
| 232 |
+
"""Save signing key (chmod 0600) and verify key to keys_dir."""
|
| 233 |
+
keys_dir.mkdir(parents=True, exist_ok=True)
|
| 234 |
+
priv_path = keys_dir / "device.ed25519"
|
| 235 |
+
pub_path = keys_dir / "device.pub"
|
| 236 |
+
# Write private key (raw 32-byte seed, base64url encoded)
|
| 237 |
+
sk_bytes = bytes(kp.signing_key)
|
| 238 |
+
priv_path.write_bytes(
|
| 239 |
+
base64.urlsafe_b64encode(sk_bytes).rstrip(b"=") + b"\n"
|
| 240 |
+
)
|
| 241 |
+
# Restrict permissions on POSIX
|
| 242 |
+
try:
|
| 243 |
+
os.chmod(priv_path, stat.S_IRUSR | stat.S_IWUSR) # 0600
|
| 244 |
+
except AttributeError:
|
| 245 |
+
pass # Windows: chmod semantics differ; best-effort
|
| 246 |
+
# Write public key
|
| 247 |
+
vk_bytes = bytes(kp.verify_key)
|
| 248 |
+
pub_path.write_bytes(
|
| 249 |
+
base64.urlsafe_b64encode(vk_bytes).rstrip(b"=") + b"\n"
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def load(keys_dir: Path) -> KeyPair:
|
| 254 |
+
"""Load KeyPair from device.ed25519 + device.pub in keys_dir."""
|
| 255 |
+
if not _NACL_AVAILABLE:
|
| 256 |
+
raise IdentityError("keys_invalid", reason="PyNaCl not installed")
|
| 257 |
+
priv_path = keys_dir / "device.ed25519"
|
| 258 |
+
pub_path = keys_dir / "device.pub"
|
| 259 |
+
if not priv_path.exists() or not pub_path.exists():
|
| 260 |
+
raise IdentityError("keys_missing", reason=f"Key files not found in {keys_dir}")
|
| 261 |
+
# Check permissions on POSIX
|
| 262 |
+
try:
|
| 263 |
+
mode = oct(stat.S_IMODE(priv_path.stat().st_mode))
|
| 264 |
+
if not mode.endswith("600") and not mode.endswith("400"):
|
| 265 |
+
raise IdentityError(
|
| 266 |
+
"keys_permissions",
|
| 267 |
+
reason=f"Private key {priv_path} has unsafe permissions {mode}",
|
| 268 |
+
)
|
| 269 |
+
except AttributeError:
|
| 270 |
+
pass # Windows
|
| 271 |
+
try:
|
| 272 |
+
sk_b64 = priv_path.read_text().strip()
|
| 273 |
+
padding = 4 - len(sk_b64) % 4
|
| 274 |
+
if padding != 4:
|
| 275 |
+
sk_b64 += "=" * padding
|
| 276 |
+
sk_bytes = base64.urlsafe_b64decode(sk_b64)
|
| 277 |
+
sk = nacl.signing.SigningKey(sk_bytes)
|
| 278 |
+
except IdentityError:
|
| 279 |
+
raise
|
| 280 |
+
except Exception as exc:
|
| 281 |
+
raise IdentityError("keys_invalid", reason=str(exc)) from exc
|
| 282 |
+
vk = sk.verify_key
|
| 283 |
+
vk_bytes = bytes(vk)
|
| 284 |
+
return KeyPair(
|
| 285 |
+
signing_key=sk,
|
| 286 |
+
verify_key=vk,
|
| 287 |
+
node_id_short=short_node_id(vk_bytes),
|
| 288 |
+
node_id_full=full_node_id(vk_bytes),
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def load_or_generate(keys_dir: Path) -> KeyPair:
|
| 293 |
+
"""Load keys if present, otherwise generate and persist."""
|
| 294 |
+
priv_path = keys_dir / "device.ed25519"
|
| 295 |
+
if priv_path.exists():
|
| 296 |
+
return load(keys_dir)
|
| 297 |
+
kp = generate()
|
| 298 |
+
save(kp, keys_dir)
|
| 299 |
+
return kp
|
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from datetime import datetime, timedelta, timezone
|
| 5 |
+
from typing import Any, Optional
|
| 6 |
+
|
| 7 |
+
from hearthnet.identity.keys import (
|
| 8 |
+
IdentityError,
|
| 9 |
+
KeyPair,
|
| 10 |
+
parse_node_id,
|
| 11 |
+
sign_payload,
|
| 12 |
+
verify_payload,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
import nacl.signing
|
| 17 |
+
|
| 18 |
+
_NACL_AVAILABLE = True
|
| 19 |
+
except ImportError: # pragma: no cover
|
| 20 |
+
_NACL_AVAILABLE = False
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Errors
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ManifestError(Exception):
|
| 28 |
+
"""Raised for manifest validation failures."""
|
| 29 |
+
|
| 30 |
+
def __init__(self, code: str, reason: str = "") -> None:
|
| 31 |
+
super().__init__(reason or code)
|
| 32 |
+
self.code = code
|
| 33 |
+
self.reason = reason
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ---------------------------------------------------------------------------
|
| 37 |
+
# Value types
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@dataclass(frozen=True)
|
| 42 |
+
class Endpoint:
|
| 43 |
+
transport: str
|
| 44 |
+
host: str
|
| 45 |
+
port: int
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass(frozen=True)
|
| 49 |
+
class HardwareSpec:
|
| 50 |
+
gpu: str | None
|
| 51 |
+
ram_gb: float
|
| 52 |
+
cpu_cores: int
|
| 53 |
+
disk_free_gb: float
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@dataclass(frozen=True)
|
| 57 |
+
class CapabilitySpec:
|
| 58 |
+
name: str
|
| 59 |
+
version: str
|
| 60 |
+
stability: str
|
| 61 |
+
params: dict
|
| 62 |
+
max_concurrent: int
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ---------------------------------------------------------------------------
|
| 66 |
+
# NodeManifest
|
| 67 |
+
# ---------------------------------------------------------------------------
|
| 68 |
+
|
| 69 |
+
_NODE_MANIFEST_TTL_SECONDS = 30
|
| 70 |
+
_COMMUNITY_MANIFEST_TTL_SECONDS = 86400
|
| 71 |
+
|
| 72 |
+
_REQUIRED_NODE_FIELDS = {
|
| 73 |
+
"version", "node_id", "display_name", "community_id", "profile",
|
| 74 |
+
"endpoints", "capabilities", "issued_at", "expires_at", "contract_version",
|
| 75 |
+
"signature",
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
_REQUIRED_COMMUNITY_FIELDS = {
|
| 79 |
+
"version", "community_id", "name", "root_node_id", "members", "policy",
|
| 80 |
+
"issued_at", "expires_at", "contract_version", "signature",
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _parse_rfc3339(s: str) -> datetime:
|
| 85 |
+
"""Parse RFC 3339 UTC timestamp."""
|
| 86 |
+
# Accept trailing Z or +00:00
|
| 87 |
+
s = s.rstrip("Z")
|
| 88 |
+
if "+" in s:
|
| 89 |
+
s = s[: s.index("+")]
|
| 90 |
+
return datetime.fromisoformat(s).replace(tzinfo=timezone.utc)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _now_utc() -> datetime:
|
| 94 |
+
return datetime.now(timezone.utc)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _rfc3339(dt: datetime) -> str:
|
| 98 |
+
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
@dataclass(frozen=True)
|
| 102 |
+
class NodeManifest:
|
| 103 |
+
version: int
|
| 104 |
+
node_id: str
|
| 105 |
+
display_name: str
|
| 106 |
+
community_id: str
|
| 107 |
+
profile: str
|
| 108 |
+
endpoints: list
|
| 109 |
+
capabilities: list
|
| 110 |
+
hardware: HardwareSpec | None
|
| 111 |
+
issued_at: str
|
| 112 |
+
expires_at: str
|
| 113 |
+
contract_version: str
|
| 114 |
+
signature: str
|
| 115 |
+
|
| 116 |
+
def is_expired(self, now: datetime | None = None) -> bool:
|
| 117 |
+
ts = now or _now_utc()
|
| 118 |
+
try:
|
| 119 |
+
exp = _parse_rfc3339(self.expires_at)
|
| 120 |
+
except (ValueError, AttributeError):
|
| 121 |
+
return True
|
| 122 |
+
return ts >= exp
|
| 123 |
+
|
| 124 |
+
def as_dict(self) -> dict:
|
| 125 |
+
d: dict[str, Any] = {
|
| 126 |
+
"version": self.version,
|
| 127 |
+
"node_id": self.node_id,
|
| 128 |
+
"display_name": self.display_name,
|
| 129 |
+
"community_id": self.community_id,
|
| 130 |
+
"profile": self.profile,
|
| 131 |
+
"endpoints": [
|
| 132 |
+
{"transport": e.transport, "host": e.host, "port": e.port}
|
| 133 |
+
for e in self.endpoints
|
| 134 |
+
],
|
| 135 |
+
"capabilities": [
|
| 136 |
+
{
|
| 137 |
+
"name": c.name,
|
| 138 |
+
"version": c.version,
|
| 139 |
+
"stability": c.stability,
|
| 140 |
+
"params": c.params,
|
| 141 |
+
"max_concurrent": c.max_concurrent,
|
| 142 |
+
}
|
| 143 |
+
for c in self.capabilities
|
| 144 |
+
],
|
| 145 |
+
"issued_at": self.issued_at,
|
| 146 |
+
"expires_at": self.expires_at,
|
| 147 |
+
"contract_version": self.contract_version,
|
| 148 |
+
"signature": self.signature,
|
| 149 |
+
}
|
| 150 |
+
if self.hardware is not None:
|
| 151 |
+
d["hardware"] = {
|
| 152 |
+
"gpu": self.hardware.gpu,
|
| 153 |
+
"ram_gb": self.hardware.ram_gb,
|
| 154 |
+
"cpu_cores": self.hardware.cpu_cores,
|
| 155 |
+
"disk_free_gb": self.hardware.disk_free_gb,
|
| 156 |
+
}
|
| 157 |
+
return d
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@dataclass(frozen=True)
|
| 161 |
+
class CommunityManifest:
|
| 162 |
+
version: int
|
| 163 |
+
community_id: str
|
| 164 |
+
name: str
|
| 165 |
+
root_node_id: str
|
| 166 |
+
members: list
|
| 167 |
+
policy: dict
|
| 168 |
+
issued_at: str
|
| 169 |
+
expires_at: str
|
| 170 |
+
contract_version: str
|
| 171 |
+
signature: str
|
| 172 |
+
|
| 173 |
+
def is_expired(self, now: datetime | None = None) -> bool:
|
| 174 |
+
ts = now or _now_utc()
|
| 175 |
+
try:
|
| 176 |
+
exp = _parse_rfc3339(self.expires_at)
|
| 177 |
+
except (ValueError, AttributeError):
|
| 178 |
+
return True
|
| 179 |
+
return ts >= exp
|
| 180 |
+
|
| 181 |
+
def as_dict(self) -> dict:
|
| 182 |
+
return {
|
| 183 |
+
"version": self.version,
|
| 184 |
+
"community_id": self.community_id,
|
| 185 |
+
"name": self.name,
|
| 186 |
+
"root_node_id": self.root_node_id,
|
| 187 |
+
"members": list(self.members),
|
| 188 |
+
"policy": dict(self.policy),
|
| 189 |
+
"issued_at": self.issued_at,
|
| 190 |
+
"expires_at": self.expires_at,
|
| 191 |
+
"contract_version": self.contract_version,
|
| 192 |
+
"signature": self.signature,
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
# ---------------------------------------------------------------------------
|
| 197 |
+
# Builders
|
| 198 |
+
# ---------------------------------------------------------------------------
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def build_node_manifest(
|
| 202 |
+
kp: KeyPair,
|
| 203 |
+
display_name: str,
|
| 204 |
+
community_id: str,
|
| 205 |
+
profile: str,
|
| 206 |
+
endpoints: list[Endpoint],
|
| 207 |
+
capabilities: list[CapabilitySpec],
|
| 208 |
+
hardware: HardwareSpec | None = None,
|
| 209 |
+
) -> NodeManifest:
|
| 210 |
+
now = _now_utc()
|
| 211 |
+
issued_at = _rfc3339(now)
|
| 212 |
+
expires_at = _rfc3339(now + timedelta(seconds=_NODE_MANIFEST_TTL_SECONDS))
|
| 213 |
+
payload: dict[str, Any] = {
|
| 214 |
+
"version": 1,
|
| 215 |
+
"node_id": kp.node_id_full,
|
| 216 |
+
"display_name": display_name,
|
| 217 |
+
"community_id": community_id,
|
| 218 |
+
"profile": profile,
|
| 219 |
+
"endpoints": [
|
| 220 |
+
{"transport": e.transport, "host": e.host, "port": e.port}
|
| 221 |
+
for e in endpoints
|
| 222 |
+
],
|
| 223 |
+
"capabilities": [
|
| 224 |
+
{
|
| 225 |
+
"name": c.name,
|
| 226 |
+
"version": c.version,
|
| 227 |
+
"stability": c.stability,
|
| 228 |
+
"params": c.params,
|
| 229 |
+
"max_concurrent": c.max_concurrent,
|
| 230 |
+
}
|
| 231 |
+
for c in capabilities
|
| 232 |
+
],
|
| 233 |
+
"issued_at": issued_at,
|
| 234 |
+
"expires_at": expires_at,
|
| 235 |
+
"contract_version": "1.0",
|
| 236 |
+
}
|
| 237 |
+
if hardware is not None:
|
| 238 |
+
payload["hardware"] = {
|
| 239 |
+
"gpu": hardware.gpu,
|
| 240 |
+
"ram_gb": hardware.ram_gb,
|
| 241 |
+
"cpu_cores": hardware.cpu_cores,
|
| 242 |
+
"disk_free_gb": hardware.disk_free_gb,
|
| 243 |
+
}
|
| 244 |
+
signed = sign_payload(payload, kp)
|
| 245 |
+
return NodeManifest(
|
| 246 |
+
version=signed["version"],
|
| 247 |
+
node_id=signed["node_id"],
|
| 248 |
+
display_name=signed["display_name"],
|
| 249 |
+
community_id=signed["community_id"],
|
| 250 |
+
profile=signed["profile"],
|
| 251 |
+
endpoints=[
|
| 252 |
+
Endpoint(transport=e["transport"], host=e["host"], port=e["port"])
|
| 253 |
+
for e in signed["endpoints"]
|
| 254 |
+
],
|
| 255 |
+
capabilities=[
|
| 256 |
+
CapabilitySpec(
|
| 257 |
+
name=c["name"],
|
| 258 |
+
version=c["version"],
|
| 259 |
+
stability=c["stability"],
|
| 260 |
+
params=c["params"],
|
| 261 |
+
max_concurrent=c["max_concurrent"],
|
| 262 |
+
)
|
| 263 |
+
for c in signed["capabilities"]
|
| 264 |
+
],
|
| 265 |
+
hardware=hardware,
|
| 266 |
+
issued_at=signed["issued_at"],
|
| 267 |
+
expires_at=signed["expires_at"],
|
| 268 |
+
contract_version=signed["contract_version"],
|
| 269 |
+
signature=signed["signature"],
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def verify_node_manifest(manifest_dict: dict) -> NodeManifest:
|
| 274 |
+
"""Verify signature and expiry, return NodeManifest."""
|
| 275 |
+
missing = _REQUIRED_NODE_FIELDS - set(manifest_dict.keys())
|
| 276 |
+
if missing:
|
| 277 |
+
raise ManifestError("missing_field", reason=f"Missing fields: {missing}")
|
| 278 |
+
node_id = manifest_dict.get("node_id", "")
|
| 279 |
+
try:
|
| 280 |
+
vk_bytes = parse_node_id(node_id)
|
| 281 |
+
except ValueError as exc:
|
| 282 |
+
raise ManifestError("schema_error", reason=str(exc)) from exc
|
| 283 |
+
if not _NACL_AVAILABLE:
|
| 284 |
+
raise ManifestError("invalid_signature", reason="PyNaCl not installed")
|
| 285 |
+
try:
|
| 286 |
+
vk = nacl.signing.VerifyKey(vk_bytes)
|
| 287 |
+
except Exception as exc:
|
| 288 |
+
raise ManifestError("schema_error", reason=str(exc)) from exc
|
| 289 |
+
try:
|
| 290 |
+
verify_payload(manifest_dict, vk)
|
| 291 |
+
except IdentityError as exc:
|
| 292 |
+
raise ManifestError("invalid_signature", reason=exc.reason) from exc
|
| 293 |
+
# Check expiry
|
| 294 |
+
expires_at = manifest_dict.get("expires_at", "")
|
| 295 |
+
try:
|
| 296 |
+
exp = _parse_rfc3339(expires_at)
|
| 297 |
+
except (ValueError, AttributeError) as exc:
|
| 298 |
+
raise ManifestError("schema_error", reason=f"Invalid expires_at: {expires_at}") from exc
|
| 299 |
+
if _now_utc() >= exp:
|
| 300 |
+
raise ManifestError("expired", reason=f"Manifest expired at {expires_at}")
|
| 301 |
+
hw_dict = manifest_dict.get("hardware")
|
| 302 |
+
hardware = (
|
| 303 |
+
HardwareSpec(
|
| 304 |
+
gpu=hw_dict.get("gpu"),
|
| 305 |
+
ram_gb=hw_dict["ram_gb"],
|
| 306 |
+
cpu_cores=hw_dict["cpu_cores"],
|
| 307 |
+
disk_free_gb=hw_dict["disk_free_gb"],
|
| 308 |
+
)
|
| 309 |
+
if hw_dict
|
| 310 |
+
else None
|
| 311 |
+
)
|
| 312 |
+
return NodeManifest(
|
| 313 |
+
version=manifest_dict["version"],
|
| 314 |
+
node_id=manifest_dict["node_id"],
|
| 315 |
+
display_name=manifest_dict["display_name"],
|
| 316 |
+
community_id=manifest_dict["community_id"],
|
| 317 |
+
profile=manifest_dict["profile"],
|
| 318 |
+
endpoints=[
|
| 319 |
+
Endpoint(transport=e["transport"], host=e["host"], port=e["port"])
|
| 320 |
+
for e in manifest_dict["endpoints"]
|
| 321 |
+
],
|
| 322 |
+
capabilities=[
|
| 323 |
+
CapabilitySpec(
|
| 324 |
+
name=c["name"],
|
| 325 |
+
version=c["version"],
|
| 326 |
+
stability=c["stability"],
|
| 327 |
+
params=c["params"],
|
| 328 |
+
max_concurrent=c["max_concurrent"],
|
| 329 |
+
)
|
| 330 |
+
for c in manifest_dict["capabilities"]
|
| 331 |
+
],
|
| 332 |
+
hardware=hardware,
|
| 333 |
+
issued_at=manifest_dict["issued_at"],
|
| 334 |
+
expires_at=manifest_dict["expires_at"],
|
| 335 |
+
contract_version=manifest_dict["contract_version"],
|
| 336 |
+
signature=manifest_dict["signature"],
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
def build_community_manifest(
|
| 341 |
+
kp: KeyPair,
|
| 342 |
+
name: str,
|
| 343 |
+
members: list[str],
|
| 344 |
+
policy: dict,
|
| 345 |
+
) -> CommunityManifest:
|
| 346 |
+
now = _now_utc()
|
| 347 |
+
issued_at = _rfc3339(now)
|
| 348 |
+
expires_at = _rfc3339(now + timedelta(seconds=_COMMUNITY_MANIFEST_TTL_SECONDS))
|
| 349 |
+
community_id = kp.node_id_full
|
| 350 |
+
payload: dict[str, Any] = {
|
| 351 |
+
"version": 1,
|
| 352 |
+
"community_id": community_id,
|
| 353 |
+
"name": name,
|
| 354 |
+
"root_node_id": kp.node_id_full,
|
| 355 |
+
"members": list(members),
|
| 356 |
+
"policy": dict(policy),
|
| 357 |
+
"issued_at": issued_at,
|
| 358 |
+
"expires_at": expires_at,
|
| 359 |
+
"contract_version": "1.0",
|
| 360 |
+
}
|
| 361 |
+
signed = sign_payload(payload, kp)
|
| 362 |
+
return CommunityManifest(
|
| 363 |
+
version=signed["version"],
|
| 364 |
+
community_id=signed["community_id"],
|
| 365 |
+
name=signed["name"],
|
| 366 |
+
root_node_id=signed["root_node_id"],
|
| 367 |
+
members=signed["members"],
|
| 368 |
+
policy=signed["policy"],
|
| 369 |
+
issued_at=signed["issued_at"],
|
| 370 |
+
expires_at=signed["expires_at"],
|
| 371 |
+
contract_version=signed["contract_version"],
|
| 372 |
+
signature=signed["signature"],
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def verify_community_manifest(manifest_dict: dict) -> CommunityManifest:
|
| 377 |
+
"""Verify signature and expiry, return CommunityManifest."""
|
| 378 |
+
missing = _REQUIRED_COMMUNITY_FIELDS - set(manifest_dict.keys())
|
| 379 |
+
if missing:
|
| 380 |
+
raise ManifestError("missing_field", reason=f"Missing fields: {missing}")
|
| 381 |
+
root_node_id = manifest_dict.get("root_node_id", "")
|
| 382 |
+
try:
|
| 383 |
+
vk_bytes = parse_node_id(root_node_id)
|
| 384 |
+
except ValueError as exc:
|
| 385 |
+
raise ManifestError("schema_error", reason=str(exc)) from exc
|
| 386 |
+
if not _NACL_AVAILABLE:
|
| 387 |
+
raise ManifestError("invalid_signature", reason="PyNaCl not installed")
|
| 388 |
+
try:
|
| 389 |
+
vk = nacl.signing.VerifyKey(vk_bytes)
|
| 390 |
+
except Exception as exc:
|
| 391 |
+
raise ManifestError("schema_error", reason=str(exc)) from exc
|
| 392 |
+
try:
|
| 393 |
+
verify_payload(manifest_dict, vk)
|
| 394 |
+
except IdentityError as exc:
|
| 395 |
+
raise ManifestError("invalid_signature", reason=exc.reason) from exc
|
| 396 |
+
expires_at = manifest_dict.get("expires_at", "")
|
| 397 |
+
try:
|
| 398 |
+
exp = _parse_rfc3339(expires_at)
|
| 399 |
+
except (ValueError, AttributeError) as exc:
|
| 400 |
+
raise ManifestError("schema_error", reason=f"Invalid expires_at: {expires_at}") from exc
|
| 401 |
+
if _now_utc() >= exp:
|
| 402 |
+
raise ManifestError("expired", reason=f"Manifest expired at {expires_at}")
|
| 403 |
+
return CommunityManifest(
|
| 404 |
+
version=manifest_dict["version"],
|
| 405 |
+
community_id=manifest_dict["community_id"],
|
| 406 |
+
name=manifest_dict["name"],
|
| 407 |
+
root_node_id=manifest_dict["root_node_id"],
|
| 408 |
+
members=manifest_dict["members"],
|
| 409 |
+
policy=manifest_dict["policy"],
|
| 410 |
+
issued_at=manifest_dict["issued_at"],
|
| 411 |
+
expires_at=manifest_dict["expires_at"],
|
| 412 |
+
contract_version=manifest_dict["contract_version"],
|
| 413 |
+
signature=manifest_dict["signature"],
|
| 414 |
+
)
|
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
"""tokens.py — Phase 2 stub for capability tokens.
|
| 4 |
+
|
| 5 |
+
All functions raise NotImplementedError. Implement in Phase 2.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from dataclasses import dataclass
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@dataclass(frozen=True)
|
| 13 |
+
class CapabilityToken:
|
| 14 |
+
"""Stub for a signed capability token (Phase 2)."""
|
| 15 |
+
|
| 16 |
+
token_id: str
|
| 17 |
+
issuer_node_id: str
|
| 18 |
+
subject_node_id: str
|
| 19 |
+
capability: str
|
| 20 |
+
issued_at: str
|
| 21 |
+
expires_at: str
|
| 22 |
+
signature: str
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def issue_token(
|
| 26 |
+
issuer_kp: Any,
|
| 27 |
+
subject_node_id: str,
|
| 28 |
+
capability: str,
|
| 29 |
+
ttl_seconds: int = 300,
|
| 30 |
+
) -> CapabilityToken:
|
| 31 |
+
"""Issue a signed capability token. Phase 2 — not yet implemented."""
|
| 32 |
+
raise NotImplementedError("CapabilityToken issuance is a Phase 2 feature.")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def verify_token(token: CapabilityToken) -> bool:
|
| 36 |
+
"""Verify a capability token's signature and expiry. Phase 2 — not yet implemented."""
|
| 37 |
+
raise NotImplementedError("CapabilityToken verification is a Phase 2 feature.")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def revoke_token(token_id: str) -> None:
|
| 41 |
+
"""Revoke a capability token by ID. Phase 2 — not yet implemented."""
|
| 42 |
+
raise NotImplementedError("CapabilityToken revocation is a Phase 2 feature.")
|
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HearthNet — X03 Observability package.
|
| 2 |
+
|
| 3 |
+
Re-exports the public surface of all sub-modules so callers can do::
|
| 4 |
+
|
| 5 |
+
from hearthnet.observability import get_logger, configure, new_trace, span
|
| 6 |
+
|
| 7 |
+
Full imports:
|
| 8 |
+
from hearthnet.observability.logging import JsonFormatter, RateLimitedLogger
|
| 9 |
+
from hearthnet.observability.metrics import counter, histogram, gauge
|
| 10 |
+
from hearthnet.observability.tracing import Trace, Span, TraceRingBuffer
|
| 11 |
+
from hearthnet.observability.doctor import run_all, run_one
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from hearthnet.observability.logging import (
|
| 16 |
+
JsonFormatter,
|
| 17 |
+
RateLimitedLogger,
|
| 18 |
+
configure,
|
| 19 |
+
get_logger,
|
| 20 |
+
)
|
| 21 |
+
from hearthnet.observability.tracing import (
|
| 22 |
+
attach,
|
| 23 |
+
current_trace,
|
| 24 |
+
get_ring_buffer,
|
| 25 |
+
new_trace,
|
| 26 |
+
span,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
__all__ = [
|
| 30 |
+
# logging
|
| 31 |
+
"configure",
|
| 32 |
+
"get_logger",
|
| 33 |
+
"JsonFormatter",
|
| 34 |
+
"RateLimitedLogger",
|
| 35 |
+
# tracing
|
| 36 |
+
"attach",
|
| 37 |
+
"current_trace",
|
| 38 |
+
"get_ring_buffer",
|
| 39 |
+
"new_trace",
|
| 40 |
+
"span",
|
| 41 |
+
]
|
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HearthNet — X03 Observability: Self-diagnostics (doctor).
|
| 2 |
+
|
| 3 |
+
Public API:
|
| 4 |
+
run_all() — run every registered check, return results
|
| 5 |
+
run_one(name) — run a single check by name
|
| 6 |
+
DoctorCheck — dataclass describing a check
|
| 7 |
+
DoctorResult — dataclass with check outcome
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import shutil
|
| 12 |
+
import socket
|
| 13 |
+
from dataclasses import dataclass, field
|
| 14 |
+
from typing import Any, Callable
|
| 15 |
+
|
| 16 |
+
from hearthnet.config import _default_config_path, _xdg_config
|
| 17 |
+
|
| 18 |
+
# ── Dataclasses ──────────────────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class DoctorCheck:
|
| 22 |
+
name: str
|
| 23 |
+
description: str
|
| 24 |
+
fix_hint: str = ""
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@dataclass
|
| 28 |
+
class DoctorResult:
|
| 29 |
+
check: DoctorCheck
|
| 30 |
+
passed: bool
|
| 31 |
+
message: str
|
| 32 |
+
extra: dict[str, Any] = field(default_factory=dict)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ── Check registry ───────────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
_CHECK_FN: dict[str, tuple[DoctorCheck, Callable[[], DoctorResult]]] = {}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _register(check: DoctorCheck) -> Callable:
|
| 41 |
+
def _decorator(fn: Callable[[], DoctorResult]) -> Callable[[], DoctorResult]:
|
| 42 |
+
_CHECK_FN[check.name] = (check, fn)
|
| 43 |
+
return fn
|
| 44 |
+
return _decorator
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ── Built-in checks ──────────────────────────────────────────────────────────
|
| 48 |
+
|
| 49 |
+
_KEYS_CHECK = DoctorCheck(
|
| 50 |
+
name="keys_present",
|
| 51 |
+
description="Check that the keys directory exists.",
|
| 52 |
+
fix_hint="Run `hearthnet keys generate` to create a device key-pair.",
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
@_register(_KEYS_CHECK)
|
| 56 |
+
def _keys_present() -> DoctorResult:
|
| 57 |
+
keys_dir = _xdg_config() / "keys"
|
| 58 |
+
exists = keys_dir.is_dir()
|
| 59 |
+
return DoctorResult(
|
| 60 |
+
check=_KEYS_CHECK,
|
| 61 |
+
passed=exists,
|
| 62 |
+
message=f"Keys directory {'found' if exists else 'missing'}: {keys_dir}",
|
| 63 |
+
extra={"path": str(keys_dir)},
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
_KEYS_LOADABLE_CHECK = DoctorCheck(
|
| 68 |
+
name="keys_loadable",
|
| 69 |
+
description="Verify that device.pub can be read from the keys directory.",
|
| 70 |
+
fix_hint="Run `hearthnet keys generate` or restore the key file.",
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
@_register(_KEYS_LOADABLE_CHECK)
|
| 74 |
+
def _keys_loadable() -> DoctorResult:
|
| 75 |
+
pub = _xdg_config() / "keys" / "device.pub"
|
| 76 |
+
try:
|
| 77 |
+
data = pub.read_bytes()
|
| 78 |
+
return DoctorResult(
|
| 79 |
+
check=_KEYS_LOADABLE_CHECK,
|
| 80 |
+
passed=True,
|
| 81 |
+
message=f"device.pub read OK ({len(data)} bytes)",
|
| 82 |
+
extra={"path": str(pub), "size": len(data)},
|
| 83 |
+
)
|
| 84 |
+
except FileNotFoundError:
|
| 85 |
+
return DoctorResult(
|
| 86 |
+
check=_KEYS_LOADABLE_CHECK,
|
| 87 |
+
passed=False,
|
| 88 |
+
message=f"device.pub not found at {pub}",
|
| 89 |
+
extra={"path": str(pub)},
|
| 90 |
+
)
|
| 91 |
+
except OSError as exc:
|
| 92 |
+
return DoctorResult(
|
| 93 |
+
check=_KEYS_LOADABLE_CHECK,
|
| 94 |
+
passed=False,
|
| 95 |
+
message=f"Could not read device.pub: {exc}",
|
| 96 |
+
extra={"path": str(pub), "error": str(exc)},
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
_CONFIG_CHECK = DoctorCheck(
|
| 101 |
+
name="config_loadable",
|
| 102 |
+
description="Verify that config.toml can be parsed.",
|
| 103 |
+
fix_hint="Run `hearthnet config init` or fix syntax in config.toml.",
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
@_register(_CONFIG_CHECK)
|
| 107 |
+
def _config_loadable() -> DoctorResult:
|
| 108 |
+
cfg_path = _default_config_path()
|
| 109 |
+
if not cfg_path.exists():
|
| 110 |
+
return DoctorResult(
|
| 111 |
+
check=_CONFIG_CHECK,
|
| 112 |
+
passed=False,
|
| 113 |
+
message=f"config.toml not found at {cfg_path}",
|
| 114 |
+
extra={"path": str(cfg_path)},
|
| 115 |
+
)
|
| 116 |
+
try:
|
| 117 |
+
# Attempt to parse without full config validation
|
| 118 |
+
try:
|
| 119 |
+
import tomllib
|
| 120 |
+
except ImportError:
|
| 121 |
+
try:
|
| 122 |
+
import tomli as tomllib # type: ignore[no-redef]
|
| 123 |
+
except ImportError:
|
| 124 |
+
return DoctorResult(
|
| 125 |
+
check=_CONFIG_CHECK,
|
| 126 |
+
passed=False,
|
| 127 |
+
message="No TOML parser available (tomllib/tomli missing)",
|
| 128 |
+
)
|
| 129 |
+
with open(cfg_path, "rb") as fh:
|
| 130 |
+
tomllib.load(fh)
|
| 131 |
+
return DoctorResult(
|
| 132 |
+
check=_CONFIG_CHECK,
|
| 133 |
+
passed=True,
|
| 134 |
+
message=f"config.toml parsed OK: {cfg_path}",
|
| 135 |
+
extra={"path": str(cfg_path)},
|
| 136 |
+
)
|
| 137 |
+
except Exception as exc:
|
| 138 |
+
return DoctorResult(
|
| 139 |
+
check=_CONFIG_CHECK,
|
| 140 |
+
passed=False,
|
| 141 |
+
message=f"config.toml parse error: {exc}",
|
| 142 |
+
extra={"path": str(cfg_path), "error": str(exc)},
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
_MDNS_CHECK = DoctorCheck(
|
| 147 |
+
name="mdns_socket",
|
| 148 |
+
description="Try to bind the mDNS multicast port (5353).",
|
| 149 |
+
fix_hint="Check if another mDNS daemon (avahi, bonjour) is already running.",
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
_MDNS_PORT = 5353
|
| 153 |
+
|
| 154 |
+
@_register(_MDNS_CHECK)
|
| 155 |
+
def _mdns_socket() -> DoctorResult:
|
| 156 |
+
try:
|
| 157 |
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
| 158 |
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
| 159 |
+
try:
|
| 160 |
+
sock.bind(("", _MDNS_PORT))
|
| 161 |
+
sock.close()
|
| 162 |
+
return DoctorResult(
|
| 163 |
+
check=_MDNS_CHECK,
|
| 164 |
+
passed=True,
|
| 165 |
+
message=f"mDNS port {_MDNS_PORT} bindable",
|
| 166 |
+
extra={"port": _MDNS_PORT},
|
| 167 |
+
)
|
| 168 |
+
except OSError as exc:
|
| 169 |
+
sock.close()
|
| 170 |
+
return DoctorResult(
|
| 171 |
+
check=_MDNS_CHECK,
|
| 172 |
+
passed=False,
|
| 173 |
+
message=f"Cannot bind mDNS port {_MDNS_PORT}: {exc}",
|
| 174 |
+
extra={"port": _MDNS_PORT, "error": str(exc)},
|
| 175 |
+
)
|
| 176 |
+
except Exception as exc:
|
| 177 |
+
return DoctorResult(
|
| 178 |
+
check=_MDNS_CHECK,
|
| 179 |
+
passed=False,
|
| 180 |
+
message=f"Socket error: {exc}",
|
| 181 |
+
extra={"error": str(exc)},
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
_LOG_DIR_CHECK = DoctorCheck(
|
| 186 |
+
name="log_dir_writable",
|
| 187 |
+
description="Check that the log directory is writable.",
|
| 188 |
+
fix_hint="Ensure the process has write access to the log directory (chmod or set log_dir in config).",
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
@_register(_LOG_DIR_CHECK)
|
| 192 |
+
def _log_dir_writable() -> DoctorResult:
|
| 193 |
+
from hearthnet.config import _xdg_data
|
| 194 |
+
log_dir = _xdg_data() / "logs"
|
| 195 |
+
try:
|
| 196 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 197 |
+
test_file = log_dir / ".write_test"
|
| 198 |
+
test_file.write_text("ok")
|
| 199 |
+
test_file.unlink()
|
| 200 |
+
return DoctorResult(
|
| 201 |
+
check=_LOG_DIR_CHECK,
|
| 202 |
+
passed=True,
|
| 203 |
+
message=f"Log directory is writable: {log_dir}",
|
| 204 |
+
extra={"path": str(log_dir)},
|
| 205 |
+
)
|
| 206 |
+
except OSError as exc:
|
| 207 |
+
return DoctorResult(
|
| 208 |
+
check=_LOG_DIR_CHECK,
|
| 209 |
+
passed=False,
|
| 210 |
+
message=f"Log directory not writable: {exc}",
|
| 211 |
+
extra={"path": str(log_dir), "error": str(exc)},
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
_DISK_CHECK = DoctorCheck(
|
| 216 |
+
name="disk_space",
|
| 217 |
+
description="Warn if available disk space is below 500 MB.",
|
| 218 |
+
fix_hint="Free up disk space or move data directories to a larger volume.",
|
| 219 |
+
)
|
| 220 |
+
|
| 221 |
+
_DISK_WARN_BYTES = 500 * 1024 * 1024 # 500 MB
|
| 222 |
+
|
| 223 |
+
@_register(_DISK_CHECK)
|
| 224 |
+
def _disk_space() -> DoctorResult:
|
| 225 |
+
from hearthnet.config import _xdg_data
|
| 226 |
+
target = _xdg_data()
|
| 227 |
+
try:
|
| 228 |
+
target.mkdir(parents=True, exist_ok=True)
|
| 229 |
+
usage = shutil.disk_usage(str(target))
|
| 230 |
+
free_mb = usage.free / (1024 * 1024)
|
| 231 |
+
passed = usage.free >= _DISK_WARN_BYTES
|
| 232 |
+
return DoctorResult(
|
| 233 |
+
check=_DISK_CHECK,
|
| 234 |
+
passed=passed,
|
| 235 |
+
message=(
|
| 236 |
+
f"Disk free: {free_mb:.0f} MB"
|
| 237 |
+
if passed
|
| 238 |
+
else f"Low disk space: {free_mb:.0f} MB free (threshold 500 MB)"
|
| 239 |
+
),
|
| 240 |
+
extra={"free_bytes": usage.free, "total_bytes": usage.total, "path": str(target)},
|
| 241 |
+
)
|
| 242 |
+
except OSError as exc:
|
| 243 |
+
return DoctorResult(
|
| 244 |
+
check=_DISK_CHECK,
|
| 245 |
+
passed=False,
|
| 246 |
+
message=f"Could not check disk space: {exc}",
|
| 247 |
+
extra={"error": str(exc)},
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# ── Public functions ──────────────────────────────────────────────────────────
|
| 252 |
+
|
| 253 |
+
def run_all() -> list[DoctorResult]:
|
| 254 |
+
"""Run all registered checks and return their results."""
|
| 255 |
+
results = []
|
| 256 |
+
for _check, fn in _CHECK_FN.values():
|
| 257 |
+
try:
|
| 258 |
+
results.append(fn())
|
| 259 |
+
except Exception as exc:
|
| 260 |
+
results.append(
|
| 261 |
+
DoctorResult(
|
| 262 |
+
check=_check,
|
| 263 |
+
passed=False,
|
| 264 |
+
message=f"Check raised an unexpected error: {exc}",
|
| 265 |
+
extra={"error": str(exc)},
|
| 266 |
+
)
|
| 267 |
+
)
|
| 268 |
+
return results
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def run_one(name: str) -> DoctorResult:
|
| 272 |
+
"""Run a single check by name. Raises KeyError for unknown names."""
|
| 273 |
+
entry = _CHECK_FN.get(name)
|
| 274 |
+
if entry is None:
|
| 275 |
+
known = ", ".join(sorted(_CHECK_FN))
|
| 276 |
+
raise KeyError(f"Unknown doctor check {name!r}. Known checks: {known}")
|
| 277 |
+
_check, fn = entry
|
| 278 |
+
try:
|
| 279 |
+
return fn()
|
| 280 |
+
except Exception as exc:
|
| 281 |
+
return DoctorResult(
|
| 282 |
+
check=_check,
|
| 283 |
+
passed=False,
|
| 284 |
+
message=f"Check raised an unexpected error: {exc}",
|
| 285 |
+
extra={"error": str(exc)},
|
| 286 |
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HearthNet — X03 Observability: Structured JSON logging.
|
| 2 |
+
|
| 3 |
+
Public API:
|
| 4 |
+
configure(config) — install handlers/formatters. Idempotent.
|
| 5 |
+
get_logger(name) — return JSON-emitting stdlib logger
|
| 6 |
+
JsonFormatter — one-line JSON log records
|
| 7 |
+
RateLimitedLogger — at most one log per second per (logger, key)
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import logging
|
| 13 |
+
import logging.handlers
|
| 14 |
+
import threading
|
| 15 |
+
import time
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
from hearthnet.config import ObservabilityConfig
|
| 20 |
+
from hearthnet.constants import LOG_RETENTION_DAYS
|
| 21 |
+
|
| 22 |
+
_configured = False
|
| 23 |
+
_configure_lock = threading.Lock()
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class JsonFormatter(logging.Formatter):
|
| 27 |
+
"""Renders a LogRecord as a single JSON line."""
|
| 28 |
+
|
| 29 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 30 |
+
payload: dict[str, Any] = {
|
| 31 |
+
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S.") + f"{record.msecs:03.0f}Z",
|
| 32 |
+
"level": record.levelname.lower(),
|
| 33 |
+
"logger": record.name,
|
| 34 |
+
"msg": record.getMessage(),
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
# Attach structured extras (skip stdlib internals)
|
| 38 |
+
_SKIP = {
|
| 39 |
+
"name", "msg", "args", "created", "filename", "funcName", "levelname",
|
| 40 |
+
"levelno", "lineno", "module", "msecs", "pathname", "process",
|
| 41 |
+
"processName", "relativeCreated", "stack_info", "thread", "threadName",
|
| 42 |
+
"exc_info", "exc_text", "message",
|
| 43 |
+
}
|
| 44 |
+
for key, val in record.__dict__.items():
|
| 45 |
+
if key not in _SKIP:
|
| 46 |
+
payload[key] = val
|
| 47 |
+
|
| 48 |
+
if record.exc_info:
|
| 49 |
+
payload["exc"] = self.formatException(record.exc_info)
|
| 50 |
+
|
| 51 |
+
return json.dumps(payload, default=str, ensure_ascii=False)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def configure(config: ObservabilityConfig) -> None:
|
| 55 |
+
"""Install handlers and formatters on the root 'hearthnet' logger.
|
| 56 |
+
|
| 57 |
+
Idempotent — safe to call multiple times; only runs once.
|
| 58 |
+
"""
|
| 59 |
+
global _configured
|
| 60 |
+
with _configure_lock:
|
| 61 |
+
if _configured:
|
| 62 |
+
return
|
| 63 |
+
_configured = True
|
| 64 |
+
|
| 65 |
+
level_name = (config.log_level or "info").upper()
|
| 66 |
+
level = getattr(logging, level_name, logging.INFO)
|
| 67 |
+
|
| 68 |
+
root = logging.getLogger("hearthnet")
|
| 69 |
+
root.setLevel(level)
|
| 70 |
+
root.handlers.clear() # reset on reconfigure
|
| 71 |
+
|
| 72 |
+
formatter = JsonFormatter()
|
| 73 |
+
|
| 74 |
+
# Console handler
|
| 75 |
+
console = logging.StreamHandler()
|
| 76 |
+
console.setFormatter(formatter)
|
| 77 |
+
root.addHandler(console)
|
| 78 |
+
|
| 79 |
+
# File handler (daily rotation, 14-day retention)
|
| 80 |
+
log_dir: Path | None = config.log_dir
|
| 81 |
+
if log_dir is not None:
|
| 82 |
+
log_dir = Path(log_dir)
|
| 83 |
+
log_dir.mkdir(parents=True, exist_ok=True)
|
| 84 |
+
log_path = log_dir / "hearthnet.log"
|
| 85 |
+
file_handler = logging.handlers.TimedRotatingFileHandler(
|
| 86 |
+
filename=str(log_path),
|
| 87 |
+
when="midnight",
|
| 88 |
+
utc=True,
|
| 89 |
+
backupCount=LOG_RETENTION_DAYS,
|
| 90 |
+
encoding="utf-8",
|
| 91 |
+
)
|
| 92 |
+
file_handler.setFormatter(formatter)
|
| 93 |
+
root.addHandler(file_handler)
|
| 94 |
+
|
| 95 |
+
root.propagate = False
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def get_logger(name: str) -> logging.Logger:
|
| 99 |
+
"""Return a stdlib logger that emits JSON lines.
|
| 100 |
+
|
| 101 |
+
Convention: ``name = __name__`` of the calling module.
|
| 102 |
+
"""
|
| 103 |
+
return logging.getLogger(name)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
class RateLimitedLogger:
|
| 107 |
+
"""Wraps a Logger and suppresses duplicate messages within a 1-second window.
|
| 108 |
+
|
| 109 |
+
Keyed by ``(logger_name, message_key)`` — call with an explicit *key*
|
| 110 |
+
argument to group semantically similar messages:
|
| 111 |
+
|
| 112 |
+
rl_log.warning("peer unreachable", key="peer_unreachable")
|
| 113 |
+
"""
|
| 114 |
+
|
| 115 |
+
def __init__(self, logger: logging.Logger) -> None:
|
| 116 |
+
self._logger = logger
|
| 117 |
+
self._last: dict[tuple[str, str], float] = {}
|
| 118 |
+
self._lock = threading.Lock()
|
| 119 |
+
self._window = 1.0 # seconds
|
| 120 |
+
|
| 121 |
+
def _should_emit(self, key: str) -> bool:
|
| 122 |
+
bucket = (self._logger.name, key)
|
| 123 |
+
now = time.monotonic()
|
| 124 |
+
with self._lock:
|
| 125 |
+
last = self._last.get(bucket, 0.0)
|
| 126 |
+
if now - last >= self._window:
|
| 127 |
+
self._last[bucket] = now
|
| 128 |
+
return True
|
| 129 |
+
return False
|
| 130 |
+
|
| 131 |
+
def _emit(self, level: int, msg: str, key: str, **kwargs: Any) -> None:
|
| 132 |
+
if self._should_emit(key):
|
| 133 |
+
self._logger.log(level, msg, **kwargs)
|
| 134 |
+
|
| 135 |
+
def debug(self, msg: str, *, key: str = "", **kwargs: Any) -> None:
|
| 136 |
+
self._emit(logging.DEBUG, msg, key or msg, **kwargs)
|
| 137 |
+
|
| 138 |
+
def info(self, msg: str, *, key: str = "", **kwargs: Any) -> None:
|
| 139 |
+
self._emit(logging.INFO, msg, key or msg, **kwargs)
|
| 140 |
+
|
| 141 |
+
def warning(self, msg: str, *, key: str = "", **kwargs: Any) -> None:
|
| 142 |
+
self._emit(logging.WARNING, msg, key or msg, **kwargs)
|
| 143 |
+
|
| 144 |
+
def error(self, msg: str, *, key: str = "", **kwargs: Any) -> None:
|
| 145 |
+
self._emit(logging.ERROR, msg, key or msg, **kwargs)
|
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HearthNet — X03 Observability: Prometheus-compatible metrics.
|
| 2 |
+
|
| 3 |
+
prometheus_client is OPTIONAL. When not installed every factory returns a
|
| 4 |
+
no-op object so call sites need no conditional logic.
|
| 5 |
+
|
| 6 |
+
Public API:
|
| 7 |
+
configure(config) — initialise registries / start HTTP endpoint
|
| 8 |
+
counter(...) — Counter factory
|
| 9 |
+
histogram(...) — Histogram factory
|
| 10 |
+
gauge(...) — Gauge factory
|
| 11 |
+
disabled() -> bool — True when prometheus_client is absent or metrics off
|
| 12 |
+
|
| 13 |
+
Standard HearthNet metrics are created at module import time so they are
|
| 14 |
+
always available as module-level names.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import threading
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
from hearthnet.config import ObservabilityConfig
|
| 22 |
+
|
| 23 |
+
# ── Optional prometheus_client import ───────────────────────────────────────
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
import prometheus_client as _prom # type: ignore[import]
|
| 27 |
+
_PROM_AVAILABLE = True
|
| 28 |
+
except ImportError: # pragma: no cover
|
| 29 |
+
_prom = None # type: ignore[assignment]
|
| 30 |
+
_PROM_AVAILABLE = False
|
| 31 |
+
|
| 32 |
+
_metrics_enabled: bool = True
|
| 33 |
+
_configure_lock = threading.Lock()
|
| 34 |
+
_configured = False
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ── No-op stubs ──────────────────────────────────────────────────────────────
|
| 38 |
+
|
| 39 |
+
class _NoOpMetric:
|
| 40 |
+
"""Returned in place of a real Prometheus metric when unavailable."""
|
| 41 |
+
|
| 42 |
+
def labels(self, **_kwargs: Any) -> "_NoOpMetric":
|
| 43 |
+
return self
|
| 44 |
+
|
| 45 |
+
def inc(self, *_a: Any, **_kw: Any) -> None:
|
| 46 |
+
pass
|
| 47 |
+
|
| 48 |
+
def observe(self, *_a: Any, **_kw: Any) -> None:
|
| 49 |
+
pass
|
| 50 |
+
|
| 51 |
+
def set(self, *_a: Any, **_kw: Any) -> None:
|
| 52 |
+
pass
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
_NOOP = _NoOpMetric()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ── Factories ────────────────────────────────────────────────────────────────
|
| 59 |
+
|
| 60 |
+
def disabled() -> bool:
|
| 61 |
+
"""Return True when metrics collection is not active."""
|
| 62 |
+
return not (_PROM_AVAILABLE and _metrics_enabled)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def counter(
|
| 66 |
+
name: str,
|
| 67 |
+
doc: str,
|
| 68 |
+
labels: list[str] | None = None,
|
| 69 |
+
) -> Any:
|
| 70 |
+
"""Return a prometheus_client Counter or a no-op."""
|
| 71 |
+
if disabled():
|
| 72 |
+
return _NOOP
|
| 73 |
+
try:
|
| 74 |
+
return _prom.Counter(name, doc, labels or [])
|
| 75 |
+
except Exception:
|
| 76 |
+
return _NOOP
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def histogram(
|
| 80 |
+
name: str,
|
| 81 |
+
doc: str,
|
| 82 |
+
labels: list[str] | None = None,
|
| 83 |
+
buckets: list[float] | None = None,
|
| 84 |
+
) -> Any:
|
| 85 |
+
"""Return a prometheus_client Histogram or a no-op."""
|
| 86 |
+
if disabled():
|
| 87 |
+
return _NOOP
|
| 88 |
+
kwargs: dict[str, Any] = {}
|
| 89 |
+
if buckets is not None:
|
| 90 |
+
kwargs["buckets"] = buckets
|
| 91 |
+
try:
|
| 92 |
+
return _prom.Histogram(name, doc, labels or [], **kwargs)
|
| 93 |
+
except Exception:
|
| 94 |
+
return _NOOP
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def gauge(
|
| 98 |
+
name: str,
|
| 99 |
+
doc: str,
|
| 100 |
+
labels: list[str] | None = None,
|
| 101 |
+
) -> Any:
|
| 102 |
+
"""Return a prometheus_client Gauge or a no-op."""
|
| 103 |
+
if disabled():
|
| 104 |
+
return _NOOP
|
| 105 |
+
try:
|
| 106 |
+
return _prom.Gauge(name, doc, labels or [])
|
| 107 |
+
except Exception:
|
| 108 |
+
return _NOOP
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def configure(config: ObservabilityConfig) -> None:
|
| 112 |
+
"""Initialise metrics according to *config*. Idempotent."""
|
| 113 |
+
global _metrics_enabled, _configured
|
| 114 |
+
with _configure_lock:
|
| 115 |
+
if _configured:
|
| 116 |
+
return
|
| 117 |
+
_configured = True
|
| 118 |
+
_metrics_enabled = config.metrics_enabled
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ── Standard HearthNet metrics ───────────────────────────────────────────────
|
| 122 |
+
# Created lazily to avoid side-effects at import time when prometheus_client
|
| 123 |
+
# is not installed. Exposed as module-level singletons.
|
| 124 |
+
|
| 125 |
+
_STD: dict[str, Any] = {}
|
| 126 |
+
_std_lock = threading.Lock()
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _std(name: str, kind: str, doc: str, labels: list[str], **kw: Any) -> Any:
|
| 130 |
+
"""Return (and memoize) a named standard metric."""
|
| 131 |
+
with _std_lock:
|
| 132 |
+
if name not in _STD:
|
| 133 |
+
if kind == "counter":
|
| 134 |
+
_STD[name] = counter(name, doc, labels)
|
| 135 |
+
elif kind == "histogram":
|
| 136 |
+
_STD[name] = histogram(name, doc, labels, **kw)
|
| 137 |
+
else:
|
| 138 |
+
_STD[name] = gauge(name, doc, labels)
|
| 139 |
+
return _STD[name]
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# Convenience accessors for standard metrics -----------------------------------
|
| 143 |
+
|
| 144 |
+
def requests_total() -> Any:
|
| 145 |
+
return _std(
|
| 146 |
+
"hearthnet_requests_total", "counter",
|
| 147 |
+
"Total routed requests", ["capability", "result"],
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def request_duration_ms() -> Any:
|
| 152 |
+
return _std(
|
| 153 |
+
"hearthnet_request_duration_ms", "histogram",
|
| 154 |
+
"Request round-trip duration in milliseconds", ["capability"],
|
| 155 |
+
buckets=[5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000],
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def active_streams() -> Any:
|
| 160 |
+
return _std(
|
| 161 |
+
"hearthnet_active_streams", "gauge",
|
| 162 |
+
"Currently open streaming requests", ["capability"],
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def nodes_online() -> Any:
|
| 167 |
+
return _std(
|
| 168 |
+
"hearthnet_nodes_online", "gauge",
|
| 169 |
+
"Known online nodes per community", ["community"],
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def event_log_size() -> Any:
|
| 174 |
+
return _std(
|
| 175 |
+
"hearthnet_event_log_size", "gauge",
|
| 176 |
+
"Number of entries in the event log", ["community"],
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def emergency_mode() -> Any:
|
| 181 |
+
return _std(
|
| 182 |
+
"hearthnet_emergency_mode", "gauge",
|
| 183 |
+
"Whether emergency mode is active (1) or not (0)", ["state"],
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def blob_storage_bytes() -> Any:
|
| 188 |
+
return _std(
|
| 189 |
+
"hearthnet_blob_storage_bytes", "gauge",
|
| 190 |
+
"Total bytes stored in the blob store", [],
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def llm_tokens_generated_total() -> Any:
|
| 195 |
+
return _std(
|
| 196 |
+
"hearthnet_llm_tokens_generated_total", "counter",
|
| 197 |
+
"LLM tokens generated since startup", ["model", "backend"],
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def capability_health_success_rate() -> Any:
|
| 202 |
+
return _std(
|
| 203 |
+
"hearthnet_capability_health_success_rate", "gauge",
|
| 204 |
+
"Rolling success rate for a capability on a given node", ["capability", "node"],
|
| 205 |
+
)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def signature_failures_total() -> Any:
|
| 209 |
+
return _std(
|
| 210 |
+
"hearthnet_signature_failures_total", "counter",
|
| 211 |
+
"Signature verification failures", ["reason"],
|
| 212 |
+
)
|
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HearthNet — X03 Observability: Per-request tracing.
|
| 2 |
+
|
| 3 |
+
Uses contextvars.ContextVar so traces propagate correctly across asyncio tasks.
|
| 4 |
+
|
| 5 |
+
Public API:
|
| 6 |
+
new_trace(capability) — create and attach a fresh Trace
|
| 7 |
+
current_trace() — return the active Trace or None
|
| 8 |
+
attach(trace) — set the active Trace on this context
|
| 9 |
+
span(name, **extras) — context-manager that records a Span
|
| 10 |
+
TraceRingBuffer — thread-safe ring buffer of last N traces
|
| 11 |
+
get_ring_buffer() — module-level singleton ring buffer
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import secrets
|
| 16 |
+
import threading
|
| 17 |
+
import time
|
| 18 |
+
from collections import deque
|
| 19 |
+
from contextlib import contextmanager
|
| 20 |
+
from contextvars import ContextVar
|
| 21 |
+
from dataclasses import dataclass, field
|
| 22 |
+
from typing import Iterator
|
| 23 |
+
|
| 24 |
+
from hearthnet.constants import TRACE_RING_BUFFER_SIZE
|
| 25 |
+
|
| 26 |
+
# ── ULID approximation ───────────────────────────────────────────────────────
|
| 27 |
+
|
| 28 |
+
def _new_ulid() -> str:
|
| 29 |
+
"""Simple ULID approximation: 13-digit ms timestamp + 12 hex random chars."""
|
| 30 |
+
try:
|
| 31 |
+
from python_ulid import ULID # type: ignore[import]
|
| 32 |
+
return str(ULID())
|
| 33 |
+
except ImportError:
|
| 34 |
+
ts = str(int(time.time() * 1000)).zfill(13)
|
| 35 |
+
rand = secrets.token_hex(6).upper()
|
| 36 |
+
return ts + rand
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ── Dataclasses ──────────────────────────────────────────────────────────────
|
| 40 |
+
|
| 41 |
+
@dataclass
|
| 42 |
+
class Span:
|
| 43 |
+
name: str
|
| 44 |
+
started_at: float = field(default_factory=time.monotonic)
|
| 45 |
+
ended_at: float | None = None
|
| 46 |
+
extras: dict = field(default_factory=dict)
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def duration_ms(self) -> float | None:
|
| 50 |
+
if self.ended_at is None:
|
| 51 |
+
return None
|
| 52 |
+
return (self.ended_at - self.started_at) * 1000.0
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass
|
| 56 |
+
class Trace:
|
| 57 |
+
trace_id: str = field(default_factory=_new_ulid)
|
| 58 |
+
capability: str = ""
|
| 59 |
+
started_at: float = field(default_factory=time.monotonic)
|
| 60 |
+
spans: list[Span] = field(default_factory=list)
|
| 61 |
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False, compare=False)
|
| 62 |
+
|
| 63 |
+
def add_span(self, span: Span) -> None:
|
| 64 |
+
with self._lock:
|
| 65 |
+
self.spans.append(span)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# ── Context variable ─────────────────────────────────────────────────────────
|
| 69 |
+
|
| 70 |
+
_current_trace: ContextVar[Trace | None] = ContextVar("_current_trace", default=None)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ── Public API ───────────────────────────────────────────────────────────────
|
| 74 |
+
|
| 75 |
+
def new_trace(capability: str) -> Trace:
|
| 76 |
+
"""Create a fresh Trace, attach it to this context, and return it."""
|
| 77 |
+
trace = Trace(capability=capability)
|
| 78 |
+
_current_trace.set(trace)
|
| 79 |
+
get_ring_buffer().push(trace)
|
| 80 |
+
return trace
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def current_trace() -> Trace | None:
|
| 84 |
+
"""Return the Trace active on this context, or None."""
|
| 85 |
+
return _current_trace.get()
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def attach(trace: Trace) -> None:
|
| 89 |
+
"""Set *trace* as the active trace on this context (e.g. to propagate to a child task)."""
|
| 90 |
+
_current_trace.set(trace)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@contextmanager
|
| 94 |
+
def span(name: str, **extras: object) -> Iterator[Span]:
|
| 95 |
+
"""Context-manager that records a Span on the current Trace (if any).
|
| 96 |
+
|
| 97 |
+
Usage::
|
| 98 |
+
|
| 99 |
+
async with span("embed", model="nomic"):
|
| 100 |
+
...
|
| 101 |
+
"""
|
| 102 |
+
s = Span(name=name, extras=dict(extras))
|
| 103 |
+
trace = current_trace()
|
| 104 |
+
try:
|
| 105 |
+
yield s
|
| 106 |
+
finally:
|
| 107 |
+
s.ended_at = time.monotonic()
|
| 108 |
+
if trace is not None:
|
| 109 |
+
trace.add_span(s)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ── Ring buffer ──────────────────────────────────────────────────────────────
|
| 113 |
+
|
| 114 |
+
class TraceRingBuffer:
|
| 115 |
+
"""Thread-safe bounded ring buffer that keeps the last *maxlen* traces."""
|
| 116 |
+
|
| 117 |
+
def __init__(self, maxlen: int = TRACE_RING_BUFFER_SIZE) -> None:
|
| 118 |
+
self._buf: deque[Trace] = deque(maxlen=maxlen)
|
| 119 |
+
self._lock = threading.Lock()
|
| 120 |
+
|
| 121 |
+
def push(self, trace: Trace) -> None:
|
| 122 |
+
with self._lock:
|
| 123 |
+
self._buf.append(trace)
|
| 124 |
+
|
| 125 |
+
def snapshot(self) -> list[Trace]:
|
| 126 |
+
"""Return a copy of all buffered traces, oldest first."""
|
| 127 |
+
with self._lock:
|
| 128 |
+
return list(self._buf)
|
| 129 |
+
|
| 130 |
+
def __len__(self) -> int:
|
| 131 |
+
with self._lock:
|
| 132 |
+
return len(self._buf)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
_ring_buffer: TraceRingBuffer | None = None
|
| 136 |
+
_ring_lock = threading.Lock()
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def get_ring_buffer() -> TraceRingBuffer:
|
| 140 |
+
"""Return the module-level singleton TraceRingBuffer."""
|
| 141 |
+
global _ring_buffer
|
| 142 |
+
if _ring_buffer is None:
|
| 143 |
+
with _ring_lock:
|
| 144 |
+
if _ring_buffer is None:
|
| 145 |
+
_ring_buffer = TraceRingBuffer()
|
| 146 |
+
return _ring_buffer
|
|
@@ -1,3 +1,6 @@
|
|
| 1 |
-
from hearthnet.services.
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
__all__ = ["ChatService", "LlmService", "MarketplaceService", "RagService"]
|
|
|
|
| 1 |
+
from hearthnet.services.chat import ChatService
|
| 2 |
+
from hearthnet.services.demo import RagService
|
| 3 |
+
from hearthnet.services.llm import LlmService
|
| 4 |
+
from hearthnet.services.marketplace import MarketplaceService
|
| 5 |
|
| 6 |
__all__ = ["ChatService", "LlmService", "MarketplaceService", "RagService"]
|
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from hearthnet.services.chat.delivery import DeliveryManager
|
| 4 |
+
from hearthnet.services.chat.service import ChatService
|
| 5 |
+
from hearthnet.services.chat.views import ChatMessage, ChatView
|
| 6 |
+
|
| 7 |
+
__all__ = ["ChatService", "ChatView", "ChatMessage", "DeliveryManager"]
|
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class DeliveryManager:
|
| 7 |
+
"""Decides direct vs store-and-forward delivery."""
|
| 8 |
+
|
| 9 |
+
def __init__(self, bus=None, our_node_id: str = ""):
|
| 10 |
+
self._bus = bus
|
| 11 |
+
self._our_node_id = our_node_id
|
| 12 |
+
self._queued: list[dict] = [] # store-and-forward queue
|
| 13 |
+
|
| 14 |
+
async def deliver(self, message: dict, recipient_node_id: str) -> str:
|
| 15 |
+
"""Try direct delivery. Returns 'direct', 'queued', or 'self'."""
|
| 16 |
+
if recipient_node_id == self._our_node_id:
|
| 17 |
+
return "self"
|
| 18 |
+
|
| 19 |
+
if self._bus is not None:
|
| 20 |
+
try:
|
| 21 |
+
from hearthnet.bus.capability import RouteRequest
|
| 22 |
+
req = RouteRequest(
|
| 23 |
+
capability="chat.send",
|
| 24 |
+
version_req=(1, 0),
|
| 25 |
+
body={"input": message},
|
| 26 |
+
caller=self._our_node_id,
|
| 27 |
+
trace_id="",
|
| 28 |
+
)
|
| 29 |
+
entry = self._bus.router.route(req)
|
| 30 |
+
if entry and entry.node_id == recipient_node_id and not entry.is_local:
|
| 31 |
+
return "direct"
|
| 32 |
+
except Exception:
|
| 33 |
+
pass
|
| 34 |
+
|
| 35 |
+
# Store-and-forward
|
| 36 |
+
self._queued.append({
|
| 37 |
+
"message": message,
|
| 38 |
+
"to": recipient_node_id,
|
| 39 |
+
"queued_at": time.time(),
|
| 40 |
+
})
|
| 41 |
+
return "queued"
|
| 42 |
+
|
| 43 |
+
def get_queued(self, node_id: str) -> list[dict]:
|
| 44 |
+
return [q for q in self._queued if q["to"] == node_id]
|
| 45 |
+
|
| 46 |
+
def acknowledge(self, message_event_id: str) -> None:
|
| 47 |
+
self._queued = [
|
| 48 |
+
q for q in self._queued
|
| 49 |
+
if q["message"].get("event_id") != message_event_id
|
| 50 |
+
]
|
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import uuid
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
|
| 6 |
+
from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest
|
| 7 |
+
from hearthnet.services.chat.delivery import DeliveryManager
|
| 8 |
+
from hearthnet.services.chat.views import ChatView
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ChatService:
|
| 12 |
+
name = "chat"
|
| 13 |
+
version = "1.0"
|
| 14 |
+
|
| 15 |
+
def __init__(self, node_id: str, event_log=None, bus=None) -> None:
|
| 16 |
+
self._node_id = node_id
|
| 17 |
+
self._event_log = event_log
|
| 18 |
+
self._view = ChatView(node_id)
|
| 19 |
+
self._delivery = DeliveryManager(bus=bus, our_node_id=node_id)
|
| 20 |
+
# Backward compat: in-memory messages list
|
| 21 |
+
self.messages: list[dict] = []
|
| 22 |
+
|
| 23 |
+
def capabilities(self) -> list[tuple]:
|
| 24 |
+
return [
|
| 25 |
+
(CapabilityDescriptor(name="chat.send", max_concurrent=8, idempotent=True), self.send, None),
|
| 26 |
+
(CapabilityDescriptor(name="chat.history", max_concurrent=8, idempotent=True), self.history, None),
|
| 27 |
+
]
|
| 28 |
+
|
| 29 |
+
async def send(self, req: RouteRequest) -> dict:
|
| 30 |
+
payload = dict(req.body.get("input", {}))
|
| 31 |
+
|
| 32 |
+
if not payload.get("recipient") and not payload.get("to"):
|
| 33 |
+
return {"error": "bad_request", "message": "recipient required"}
|
| 34 |
+
|
| 35 |
+
recipient = payload.get("recipient") or payload.get("to", "")
|
| 36 |
+
|
| 37 |
+
if recipient == self._node_id:
|
| 38 |
+
return {"error": "bad_request", "message": "Cannot send to self"}
|
| 39 |
+
|
| 40 |
+
event_id = payload.get("event_id") or f"msg:{uuid.uuid4().hex}"
|
| 41 |
+
client_id = payload.get("client_id", event_id)
|
| 42 |
+
now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
| 43 |
+
|
| 44 |
+
msg_payload = {
|
| 45 |
+
"to": recipient,
|
| 46 |
+
"body": payload.get("body", ""),
|
| 47 |
+
"attachments": payload.get("attachments", []),
|
| 48 |
+
"sent_at": now,
|
| 49 |
+
"client_id": client_id,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
if self._event_log is not None:
|
| 53 |
+
try:
|
| 54 |
+
event = self._event_log.append_local(
|
| 55 |
+
event_type="chat.message.sent",
|
| 56 |
+
author=req.caller or self._node_id,
|
| 57 |
+
payload=msg_payload,
|
| 58 |
+
)
|
| 59 |
+
self._view.apply(event)
|
| 60 |
+
delivered = await self._delivery.deliver(msg_payload, recipient)
|
| 61 |
+
return {
|
| 62 |
+
"output": {
|
| 63 |
+
"event_id": event.event_id,
|
| 64 |
+
"lamport": event.lamport,
|
| 65 |
+
"delivered": delivered,
|
| 66 |
+
},
|
| 67 |
+
"meta": {},
|
| 68 |
+
}
|
| 69 |
+
except Exception:
|
| 70 |
+
pass
|
| 71 |
+
|
| 72 |
+
# Demo / backward-compat mode
|
| 73 |
+
message = {
|
| 74 |
+
"event_id": event_id,
|
| 75 |
+
"from": req.caller or self._node_id,
|
| 76 |
+
"to": recipient,
|
| 77 |
+
"body": payload.get("body", ""),
|
| 78 |
+
"attachments": payload.get("attachments", []),
|
| 79 |
+
}
|
| 80 |
+
self.messages.append(message)
|
| 81 |
+
delivered = "direct" if recipient == self._node_id else "queued"
|
| 82 |
+
return {
|
| 83 |
+
"output": {
|
| 84 |
+
"event_id": event_id,
|
| 85 |
+
"lamport": len(self.messages),
|
| 86 |
+
"delivered": delivered,
|
| 87 |
+
},
|
| 88 |
+
"meta": {},
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
async def history(self, req: RouteRequest) -> dict:
|
| 92 |
+
peer = req.body.get("input", {}).get("peer")
|
| 93 |
+
|
| 94 |
+
if self._event_log is not None:
|
| 95 |
+
if peer:
|
| 96 |
+
msgs = [m.as_dict() for m in self._view.messages_with(peer)]
|
| 97 |
+
else:
|
| 98 |
+
msgs = [m.as_dict() for m in self._view.all_messages()]
|
| 99 |
+
else:
|
| 100 |
+
msgs = [
|
| 101 |
+
m for m in self.messages
|
| 102 |
+
if peer is None or m.get("from") == peer or m.get("to") == peer
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
return {"output": {"messages": msgs}, "meta": {}}
|
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass(frozen=True)
|
| 8 |
+
class ChatMessage:
|
| 9 |
+
event_id: str
|
| 10 |
+
from_node: str
|
| 11 |
+
to_node: str
|
| 12 |
+
body: str
|
| 13 |
+
attachments: list[dict]
|
| 14 |
+
sent_at: str
|
| 15 |
+
delivered_at: str | None
|
| 16 |
+
read_at: str | None
|
| 17 |
+
client_id: str
|
| 18 |
+
|
| 19 |
+
def as_dict(self) -> dict:
|
| 20 |
+
return {
|
| 21 |
+
"event_id": self.event_id,
|
| 22 |
+
"from": self.from_node,
|
| 23 |
+
"to": self.to_node,
|
| 24 |
+
"body": self.body,
|
| 25 |
+
"attachments": self.attachments,
|
| 26 |
+
"sent_at": self.sent_at,
|
| 27 |
+
"delivered_at": self.delivered_at,
|
| 28 |
+
"read_at": self.read_at,
|
| 29 |
+
"client_id": self.client_id,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ChatView:
|
| 34 |
+
"""MaterialisedView from chat.message.* events."""
|
| 35 |
+
|
| 36 |
+
def __init__(self, our_node_id: str) -> None:
|
| 37 |
+
self._our_node_id = our_node_id
|
| 38 |
+
self._messages: dict[str, ChatMessage] = {} # event_id -> ChatMessage
|
| 39 |
+
self._seen_client_ids: set[str] = set()
|
| 40 |
+
|
| 41 |
+
def apply(self, event) -> None:
|
| 42 |
+
etype = getattr(event, "event_type", None) or event.get("event_type", "")
|
| 43 |
+
payload = getattr(event, "payload", None) or event.get("payload", {})
|
| 44 |
+
event_id = getattr(event, "event_id", None) or event.get("event_id", "")
|
| 45 |
+
author = getattr(event, "author", None) or event.get("author", "")
|
| 46 |
+
|
| 47 |
+
if etype == "chat.message.sent":
|
| 48 |
+
client_id = payload.get("client_id", event_id)
|
| 49 |
+
if client_id in self._seen_client_ids:
|
| 50 |
+
return
|
| 51 |
+
self._seen_client_ids.add(client_id)
|
| 52 |
+
msg = ChatMessage(
|
| 53 |
+
event_id=event_id,
|
| 54 |
+
from_node=author,
|
| 55 |
+
to_node=payload.get("to", ""),
|
| 56 |
+
body=payload.get("body", ""),
|
| 57 |
+
attachments=payload.get("attachments", []),
|
| 58 |
+
sent_at=payload.get("sent_at", ""),
|
| 59 |
+
delivered_at=None,
|
| 60 |
+
read_at=None,
|
| 61 |
+
client_id=client_id,
|
| 62 |
+
)
|
| 63 |
+
self._messages[event_id] = msg
|
| 64 |
+
|
| 65 |
+
elif etype == "chat.message.delivered":
|
| 66 |
+
target_id = payload.get("target_event_id", "")
|
| 67 |
+
if target_id in self._messages:
|
| 68 |
+
old = self._messages[target_id]
|
| 69 |
+
self._messages[target_id] = ChatMessage(
|
| 70 |
+
event_id=old.event_id,
|
| 71 |
+
from_node=old.from_node,
|
| 72 |
+
to_node=old.to_node,
|
| 73 |
+
body=old.body,
|
| 74 |
+
attachments=old.attachments,
|
| 75 |
+
sent_at=old.sent_at,
|
| 76 |
+
delivered_at=payload.get("delivered_at", ""),
|
| 77 |
+
read_at=old.read_at,
|
| 78 |
+
client_id=old.client_id,
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
elif etype == "chat.message.read":
|
| 82 |
+
target_id = payload.get("target_event_id", "")
|
| 83 |
+
if target_id in self._messages:
|
| 84 |
+
old = self._messages[target_id]
|
| 85 |
+
self._messages[target_id] = ChatMessage(
|
| 86 |
+
event_id=old.event_id,
|
| 87 |
+
from_node=old.from_node,
|
| 88 |
+
to_node=old.to_node,
|
| 89 |
+
body=old.body,
|
| 90 |
+
attachments=old.attachments,
|
| 91 |
+
sent_at=old.sent_at,
|
| 92 |
+
delivered_at=old.delivered_at,
|
| 93 |
+
read_at=payload.get("read_at", ""),
|
| 94 |
+
client_id=old.client_id,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
def messages_with(self, peer_node_id: str) -> list[ChatMessage]:
|
| 98 |
+
return [
|
| 99 |
+
m for m in self._messages.values()
|
| 100 |
+
if m.from_node == peer_node_id or m.to_node == peer_node_id
|
| 101 |
+
]
|
| 102 |
+
|
| 103 |
+
def all_messages(self) -> list[ChatMessage]:
|
| 104 |
+
return sorted(self._messages.values(), key=lambda m: m.sent_at)
|
| 105 |
+
|
| 106 |
+
def unread_count(self, peer: str) -> int:
|
| 107 |
+
return sum(
|
| 108 |
+
1 for m in self._messages.values()
|
| 109 |
+
if m.to_node == self._our_node_id and m.from_node == peer and m.read_at is None
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
def snapshot_state(self) -> dict:
|
| 113 |
+
return {
|
| 114 |
+
"messages": {eid: m.as_dict() for eid, m in self._messages.items()},
|
| 115 |
+
"seen_client_ids": list(self._seen_client_ids),
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
def restore_state(self, state: dict) -> None:
|
| 119 |
+
self._messages = {}
|
| 120 |
+
for eid, md in state.get("messages", {}).items():
|
| 121 |
+
self._messages[eid] = ChatMessage(
|
| 122 |
+
event_id=md["event_id"],
|
| 123 |
+
from_node=md["from"],
|
| 124 |
+
to_node=md["to"],
|
| 125 |
+
body=md["body"],
|
| 126 |
+
attachments=md.get("attachments", []),
|
| 127 |
+
sent_at=md["sent_at"],
|
| 128 |
+
delivered_at=md.get("delivered_at"),
|
| 129 |
+
read_at=md.get("read_at"),
|
| 130 |
+
client_id=md.get("client_id", eid),
|
| 131 |
+
)
|
| 132 |
+
self._seen_client_ids = set(state.get("seen_client_ids", []))
|
| 133 |
+
|
| 134 |
+
def reset(self) -> None:
|
| 135 |
+
self._messages.clear()
|
| 136 |
+
self._seen_client_ids.clear()
|
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from hearthnet.services.embedding.backends import (
|
| 4 |
+
EmbeddingBackend,
|
| 5 |
+
SentenceTransformerBackend,
|
| 6 |
+
SimpleHashBackend,
|
| 7 |
+
)
|
| 8 |
+
from hearthnet.services.embedding.service import EmbeddingService
|
| 9 |
+
|
| 10 |
+
__all__ = [
|
| 11 |
+
"EmbeddingBackend",
|
| 12 |
+
"SimpleHashBackend",
|
| 13 |
+
"SentenceTransformerBackend",
|
| 14 |
+
"EmbeddingService",
|
| 15 |
+
]
|
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Protocol, runtime_checkable
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@runtime_checkable
|
| 7 |
+
class EmbeddingBackend(Protocol):
|
| 8 |
+
name: str
|
| 9 |
+
model: str
|
| 10 |
+
dim: int
|
| 11 |
+
max_input: int
|
| 12 |
+
|
| 13 |
+
async def embed(self, texts: list[str], *, normalize: bool = True) -> list[list[float]]: ...
|
| 14 |
+
async def warm(self) -> None: ...
|
| 15 |
+
async def close(self) -> None: ...
|
| 16 |
+
def health(self) -> dict: ...
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class SimpleHashBackend:
|
| 20 |
+
"""Deterministic test backend using hash-based pseudo-embeddings. No ML deps."""
|
| 21 |
+
|
| 22 |
+
name = "simple"
|
| 23 |
+
model = "hash-16"
|
| 24 |
+
dim = 16
|
| 25 |
+
max_input = 8192
|
| 26 |
+
|
| 27 |
+
async def embed(self, texts: list[str], *, normalize: bool = True) -> list[list[float]]:
|
| 28 |
+
"""Hash each text to a 16-dim float vector. Deterministic. For testing."""
|
| 29 |
+
import hashlib
|
| 30 |
+
import struct
|
| 31 |
+
|
| 32 |
+
result = []
|
| 33 |
+
for text in texts:
|
| 34 |
+
# SHA-512 yields 64 bytes → 16 × 4-byte floats
|
| 35 |
+
h = hashlib.sha512(text.encode()).digest()
|
| 36 |
+
vec = [struct.unpack_from("f", h, i)[0] for i in range(0, 64, 4)]
|
| 37 |
+
if normalize:
|
| 38 |
+
norm = sum(x**2 for x in vec) ** 0.5 or 1.0
|
| 39 |
+
vec = [x / norm for x in vec]
|
| 40 |
+
result.append(vec)
|
| 41 |
+
return result
|
| 42 |
+
|
| 43 |
+
async def warm(self) -> None:
|
| 44 |
+
pass
|
| 45 |
+
|
| 46 |
+
async def close(self) -> None:
|
| 47 |
+
pass
|
| 48 |
+
|
| 49 |
+
def health(self) -> dict:
|
| 50 |
+
return {"backend": "simple", "status": "ok"}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class SentenceTransformerBackend:
|
| 54 |
+
"""Local backend using sentence-transformers + torch."""
|
| 55 |
+
|
| 56 |
+
name = "sentence_transformers"
|
| 57 |
+
|
| 58 |
+
def __init__(self, model: str, device: str = "auto") -> None:
|
| 59 |
+
self.model = model
|
| 60 |
+
self.dim = 384 # default for bge-small
|
| 61 |
+
self.max_input = 8192
|
| 62 |
+
self._model = None
|
| 63 |
+
self._device = device
|
| 64 |
+
|
| 65 |
+
async def embed(self, texts: list[str], *, normalize: bool = True) -> list[list[float]]:
|
| 66 |
+
"""Load model lazily on first embed call."""
|
| 67 |
+
if self._model is None:
|
| 68 |
+
await self.warm()
|
| 69 |
+
import asyncio
|
| 70 |
+
|
| 71 |
+
loop = asyncio.get_event_loop()
|
| 72 |
+
return await loop.run_in_executor(None, self._embed_sync, texts, normalize)
|
| 73 |
+
|
| 74 |
+
def _embed_sync(self, texts: list[str], normalize: bool) -> list[list[float]]:
|
| 75 |
+
embeddings = self._model.encode(
|
| 76 |
+
texts, normalize_embeddings=normalize, show_progress_bar=False
|
| 77 |
+
)
|
| 78 |
+
return [e.tolist() for e in embeddings]
|
| 79 |
+
|
| 80 |
+
async def warm(self) -> None:
|
| 81 |
+
"""Load the model in a thread to avoid blocking event loop."""
|
| 82 |
+
import asyncio
|
| 83 |
+
|
| 84 |
+
loop = asyncio.get_event_loop()
|
| 85 |
+
await loop.run_in_executor(None, self._load_model)
|
| 86 |
+
|
| 87 |
+
def _load_model(self) -> None:
|
| 88 |
+
try:
|
| 89 |
+
from sentence_transformers import SentenceTransformer
|
| 90 |
+
|
| 91 |
+
device = self._device
|
| 92 |
+
if device == "auto":
|
| 93 |
+
try:
|
| 94 |
+
import torch
|
| 95 |
+
|
| 96 |
+
device = "cuda" if torch.cuda.is_available() else "cpu"
|
| 97 |
+
except ImportError:
|
| 98 |
+
device = "cpu"
|
| 99 |
+
self._model = SentenceTransformer(self.model, device=device)
|
| 100 |
+
self.dim = self._model.get_sentence_embedding_dimension() or 384
|
| 101 |
+
except ImportError as e:
|
| 102 |
+
raise RuntimeError(f"sentence-transformers not installed: {e}") from e
|
| 103 |
+
|
| 104 |
+
async def close(self) -> None:
|
| 105 |
+
pass
|
| 106 |
+
|
| 107 |
+
def health(self) -> dict:
|
| 108 |
+
return {
|
| 109 |
+
"backend": "sentence_transformers",
|
| 110 |
+
"model": self.model,
|
| 111 |
+
"loaded": self._model is not None,
|
| 112 |
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest
|
| 4 |
+
from hearthnet.constants import EMBED_MAX_CHARS, EMBED_MAX_TEXTS
|
| 5 |
+
from hearthnet.services.embedding.backends import EmbeddingBackend, SimpleHashBackend
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class EmbeddingService:
|
| 9 |
+
name = "embedding"
|
| 10 |
+
version = "1.0"
|
| 11 |
+
|
| 12 |
+
def __init__(self, backend: EmbeddingBackend | None = None) -> None:
|
| 13 |
+
self._backend: EmbeddingBackend = backend or SimpleHashBackend()
|
| 14 |
+
|
| 15 |
+
def capabilities(self) -> list[tuple]:
|
| 16 |
+
descriptor = CapabilityDescriptor(
|
| 17 |
+
name="embed.text",
|
| 18 |
+
version=(1, 0),
|
| 19 |
+
stability="stable",
|
| 20 |
+
params={"model": self._backend.model, "dim": self._backend.dim},
|
| 21 |
+
max_concurrent=8,
|
| 22 |
+
trust_required="member",
|
| 23 |
+
timeout_seconds=30,
|
| 24 |
+
idempotent=True,
|
| 25 |
+
)
|
| 26 |
+
return [(descriptor, self.handle_embed, self._params_compatible)]
|
| 27 |
+
|
| 28 |
+
def _params_compatible(self, offered: dict, requested: dict) -> bool:
|
| 29 |
+
req_model = requested.get("model")
|
| 30 |
+
return not req_model or req_model == offered.get("model")
|
| 31 |
+
|
| 32 |
+
async def handle_embed(self, req: RouteRequest) -> dict:
|
| 33 |
+
inp = req.body.get("input", {})
|
| 34 |
+
texts = inp.get("texts", [])
|
| 35 |
+
normalize = inp.get("normalize", True)
|
| 36 |
+
|
| 37 |
+
if len(texts) > EMBED_MAX_TEXTS:
|
| 38 |
+
return {
|
| 39 |
+
"error": "bad_request",
|
| 40 |
+
"message": f"Too many texts (max {EMBED_MAX_TEXTS})",
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
for t in texts:
|
| 44 |
+
if len(t) > EMBED_MAX_CHARS:
|
| 45 |
+
return {
|
| 46 |
+
"error": "bad_request",
|
| 47 |
+
"message": f"Text too long (max {EMBED_MAX_CHARS} chars)",
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
if not texts:
|
| 51 |
+
return {
|
| 52 |
+
"output": {
|
| 53 |
+
"embeddings": [],
|
| 54 |
+
"model": self._backend.model,
|
| 55 |
+
"dim": self._backend.dim,
|
| 56 |
+
},
|
| 57 |
+
"meta": {},
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
embeddings = await self._backend.embed(texts, normalize=normalize)
|
| 62 |
+
except Exception as exc:
|
| 63 |
+
return {"error": "internal_error", "message": str(exc)}
|
| 64 |
+
|
| 65 |
+
return {
|
| 66 |
+
"output": {
|
| 67 |
+
"embeddings": embeddings,
|
| 68 |
+
"model": self._backend.model,
|
| 69 |
+
"dim": self._backend.dim,
|
| 70 |
+
},
|
| 71 |
+
"meta": {"count": len(embeddings)},
|
| 72 |
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from hearthnet.services.file.service import FileService
|
| 4 |
+
|
| 5 |
+
__all__ = ["FileService"]
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from hearthnet.blobs.store import BlobStore
|
| 7 |
+
from hearthnet.bus.capability import CapabilityDescriptor, RouteRequest
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class FileService:
|
| 11 |
+
name = "file"
|
| 12 |
+
version = "1.0"
|
| 13 |
+
|
| 14 |
+
def __init__(self, store: BlobStore) -> None:
|
| 15 |
+
self.store = store
|
| 16 |
+
|
| 17 |
+
def capabilities(self) -> list[tuple[Any, ...]]:
|
| 18 |
+
return [
|
| 19 |
+
(
|
| 20 |
+
CapabilityDescriptor(
|
| 21 |
+
name="file.read",
|
| 22 |
+
params={},
|
| 23 |
+
trust_required="member",
|
| 24 |
+
max_concurrent=8,
|
| 25 |
+
),
|
| 26 |
+
self.handle_read,
|
| 27 |
+
None,
|
| 28 |
+
),
|
| 29 |
+
(
|
| 30 |
+
CapabilityDescriptor(
|
| 31 |
+
name="file.list",
|
| 32 |
+
params={},
|
| 33 |
+
trust_required="member",
|
| 34 |
+
max_concurrent=8,
|
| 35 |
+
),
|
| 36 |
+
self.handle_list,
|
| 37 |
+
None,
|
| 38 |
+
),
|
| 39 |
+
(
|
| 40 |
+
CapabilityDescriptor(
|
| 41 |
+
name="file.advertise",
|
| 42 |
+
params={},
|
| 43 |
+
trust_required="member",
|
| 44 |
+
max_concurrent=4,
|
| 45 |
+
),
|
| 46 |
+
self.handle_advertise,
|
| 47 |
+
None,
|
| 48 |
+
),
|
| 49 |
+
(
|
| 50 |
+
CapabilityDescriptor(
|
| 51 |
+
name="file.put",
|
| 52 |
+
params={},
|
| 53 |
+
trust_required="trusted",
|
| 54 |
+
max_concurrent=2,
|
| 55 |
+
timeout_seconds=600,
|
| 56 |
+
),
|
| 57 |
+
self.handle_put,
|
| 58 |
+
None,
|
| 59 |
+
),
|
| 60 |
+
]
|
| 61 |
+
|
| 62 |
+
async def handle_read(self, req: RouteRequest) -> dict[str, Any]:
|
| 63 |
+
"""input: {cid: str} → output: {cid, size_bytes, filename, chunks: [...]}"""
|
| 64 |
+
cid = req.body.get("input", {}).get("cid", "")
|
| 65 |
+
if not self.store.has(cid):
|
| 66 |
+
return {"error": "not_found", "message": f"Blob {cid} not found"}
|
| 67 |
+
manifest = self.store.get_manifest(cid)
|
| 68 |
+
return {
|
| 69 |
+
"output": {
|
| 70 |
+
"cid": manifest.cid,
|
| 71 |
+
"size_bytes": manifest.size_bytes,
|
| 72 |
+
"filename": manifest.filename,
|
| 73 |
+
"chunks": [
|
| 74 |
+
{"index": c.index, "cid": c.cid, "size_bytes": c.size_bytes}
|
| 75 |
+
for c in manifest.chunks
|
| 76 |
+
],
|
| 77 |
+
},
|
| 78 |
+
"meta": {},
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
async def handle_list(self, req: RouteRequest) -> dict[str, Any]:
|
| 82 |
+
blobs = self.store.list_blobs()
|
| 83 |
+
return {
|
| 84 |
+
"output": {
|
| 85 |
+
"blobs": [
|
| 86 |
+
{"cid": b.cid, "size_bytes": b.size_bytes, "filename": b.filename}
|
| 87 |
+
for b in blobs
|
| 88 |
+
]
|
| 89 |
+
},
|
| 90 |
+
"meta": {},
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
async def handle_advertise(self, req: RouteRequest) -> dict[str, Any]:
|
| 94 |
+
"""input: {cid, filename, size_bytes} → acknowledge, actual transfer is separate"""
|
| 95 |
+
inp = req.body.get("input", {})
|
| 96 |
+
return {"output": {"acknowledged": True, "cid": inp.get("cid")}, "meta": {}}
|
| 97 |
+
|
| 98 |
+
async def handle_put(self, req: RouteRequest) -> dict[str, Any]:
|
| 99 |
+
"""input: {data_b64: str, filename: str} → store blob → output: {cid, size_bytes}"""
|
| 100 |
+
inp = req.body.get("input", {})
|
| 101 |
+
data_b64 = inp.get("data_b64", "")
|
| 102 |
+
filename = inp.get("filename")
|
| 103 |
+
try:
|
| 104 |
+
data = base64.b64decode(data_b64)
|
| 105 |
+
except Exception:
|
| 106 |
+
return {"error": "bad_request", "message": "Invalid base64 data"}
|
| 107 |
+
manifest = self.store.put(data, filename=filename)
|
| 108 |
+
return {"output": {"cid": manifest.cid, "size_bytes": manifest.size_bytes}, "meta": {}}
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from hearthnet.services.llm.service import LlmService
|
| 2 |
+
|
| 3 |
+
__all__ = ["LlmService"]
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
from hearthnet.services.llm.backends.base import *
|
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from typing import Any, AsyncIterator, Protocol
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass(frozen=True)
|
| 8 |
+
class Token:
|
| 9 |
+
text: str
|
| 10 |
+
logprob: float = 0.0
|
| 11 |
+
stop: bool = False
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass(frozen=True)
|
| 15 |
+
class ChatResult:
|
| 16 |
+
text: str
|
| 17 |
+
tokens_in: int
|
| 18 |
+
tokens_out: int
|
| 19 |
+
model: str
|
| 20 |
+
ms: int
|
| 21 |
+
stop_reason: str = "stop"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass(frozen=True)
|
| 25 |
+
class BackendModel:
|
| 26 |
+
name: str
|
| 27 |
+
family: str # "llama", "qwen", "mistral", etc.
|
| 28 |
+
context_length: int
|
| 29 |
+
requires_internet: bool
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class LlmBackend(Protocol):
|
| 33 |
+
name: str
|
| 34 |
+
models: list[BackendModel]
|
| 35 |
+
|
| 36 |
+
async def chat(
|
| 37 |
+
self,
|
| 38 |
+
messages: list[dict],
|
| 39 |
+
*,
|
| 40 |
+
model: str,
|
| 41 |
+
stream: bool = False,
|
| 42 |
+
temperature: float = 0.7,
|
| 43 |
+
max_tokens: int = 1024,
|
| 44 |
+
**kwargs: Any,
|
| 45 |
+
) -> ChatResult | AsyncIterator[Token]: ...
|
| 46 |
+
|
| 47 |
+
async def complete(
|
| 48 |
+
self,
|
| 49 |
+
prompt: str,
|
| 50 |
+
*,
|
| 51 |
+
model: str,
|
| 52 |
+
stream: bool = False,
|
| 53 |
+
temperature: float = 0.7,
|
| 54 |
+
max_tokens: int = 1024,
|
| 55 |
+
**kwargs: Any,
|
| 56 |
+
) -> ChatResult | AsyncIterator[Token]: ...
|
| 57 |
+
|
| 58 |
+
async def warm(self) -> None: ...
|
| 59 |
+
async def close(self) -> None: ...
|
| 60 |
+
def health(self) -> dict: ...
|
| 61 |
+
def is_available(self) -> bool: ...
|
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local HuggingFace Transformers backend."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from hearthnet.services.llm.backends.base import BackendModel, ChatResult
|
| 5 |
+
from hearthnet.services.llm.tokenizers import model_family
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _family(model_name: str) -> str:
|
| 9 |
+
return model_family(model_name)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class HfLocalBackend:
|
| 13 |
+
name = "hf_local"
|
| 14 |
+
|
| 15 |
+
def __init__(
|
| 16 |
+
self, model: str = "microsoft/DialoGPT-small", device: str = "auto"
|
| 17 |
+
) -> None:
|
| 18 |
+
self._model_name = model
|
| 19 |
+
self._device = device
|
| 20 |
+
self._pipeline = None
|
| 21 |
+
self.models = [
|
| 22 |
+
BackendModel(
|
| 23 |
+
name=model,
|
| 24 |
+
family=_family(model),
|
| 25 |
+
context_length=2048,
|
| 26 |
+
requires_internet=False,
|
| 27 |
+
)
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
def is_available(self) -> bool:
|
| 31 |
+
try:
|
| 32 |
+
import transformers
|
| 33 |
+
|
| 34 |
+
return True
|
| 35 |
+
except ImportError:
|
| 36 |
+
return False
|
| 37 |
+
|
| 38 |
+
async def warm(self) -> None:
|
| 39 |
+
if not self.is_available():
|
| 40 |
+
return
|
| 41 |
+
import asyncio
|
| 42 |
+
|
| 43 |
+
loop = asyncio.get_event_loop()
|
| 44 |
+
await loop.run_in_executor(None, self._load)
|
| 45 |
+
|
| 46 |
+
def _load(self) -> None:
|
| 47 |
+
from transformers import pipeline
|
| 48 |
+
|
| 49 |
+
device = 0 if self._device == "cuda" else -1
|
| 50 |
+
if self._device == "auto":
|
| 51 |
+
try:
|
| 52 |
+
import torch
|
| 53 |
+
|
| 54 |
+
device = 0 if torch.cuda.is_available() else -1
|
| 55 |
+
except ImportError:
|
| 56 |
+
device = -1
|
| 57 |
+
self._pipeline = pipeline(
|
| 58 |
+
"text-generation", model=self._model_name, device=device
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
async def chat(
|
| 62 |
+
self,
|
| 63 |
+
messages: list[dict],
|
| 64 |
+
*,
|
| 65 |
+
model: str = "",
|
| 66 |
+
stream: bool = False,
|
| 67 |
+
temperature: float = 0.7,
|
| 68 |
+
max_tokens: int = 256,
|
| 69 |
+
**kwargs,
|
| 70 |
+
):
|
| 71 |
+
import asyncio
|
| 72 |
+
import time
|
| 73 |
+
|
| 74 |
+
if self._pipeline is None:
|
| 75 |
+
await self.warm()
|
| 76 |
+
if self._pipeline is None:
|
| 77 |
+
raise RuntimeError("HF model not loaded")
|
| 78 |
+
t0 = time.monotonic()
|
| 79 |
+
prompt = (
|
| 80 |
+
"\n".join(f"{m['role']}: {m['content']}" for m in messages) + "\nassistant:"
|
| 81 |
+
)
|
| 82 |
+
loop = asyncio.get_event_loop()
|
| 83 |
+
result = await loop.run_in_executor(
|
| 84 |
+
None,
|
| 85 |
+
lambda: self._pipeline(
|
| 86 |
+
prompt,
|
| 87 |
+
max_new_tokens=max_tokens,
|
| 88 |
+
temperature=temperature,
|
| 89 |
+
do_sample=True,
|
| 90 |
+
return_full_text=False,
|
| 91 |
+
),
|
| 92 |
+
)
|
| 93 |
+
text = result[0]["generated_text"] if result else ""
|
| 94 |
+
ms = int((time.monotonic() - t0) * 1000)
|
| 95 |
+
return ChatResult(
|
| 96 |
+
text=text,
|
| 97 |
+
tokens_in=len(prompt.split()),
|
| 98 |
+
tokens_out=len(text.split()),
|
| 99 |
+
model=self._model_name,
|
| 100 |
+
ms=ms,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
async def complete(
|
| 104 |
+
self, prompt: str, *, model: str = "", stream: bool = False, **kwargs
|
| 105 |
+
):
|
| 106 |
+
return await self.chat(
|
| 107 |
+
[{"role": "user", "content": prompt}], model=model, stream=stream, **kwargs
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
async def close(self) -> None:
|
| 111 |
+
self._pipeline = None
|
| 112 |
+
|
| 113 |
+
def health(self) -> dict:
|
| 114 |
+
return {
|
| 115 |
+
"backend": "hf_local",
|
| 116 |
+
"model": self._model_name,
|
| 117 |
+
"loaded": self._pipeline is not None,
|
| 118 |
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""llama-cpp-python in-process backend."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from hearthnet.services.llm.backends.base import BackendModel, ChatResult, Token
|
| 5 |
+
from hearthnet.services.llm.tokenizers import model_family
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _family(model_name: str) -> str:
|
| 9 |
+
return model_family(model_name)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class LlamaCppBackend:
|
| 13 |
+
name = "llama_cpp"
|
| 14 |
+
|
| 15 |
+
def __init__(
|
| 16 |
+
self, model_path: str, n_ctx: int = 4096, n_gpu_layers: int = -1
|
| 17 |
+
) -> None:
|
| 18 |
+
self._model_path = model_path
|
| 19 |
+
self._n_ctx = n_ctx
|
| 20 |
+
self._n_gpu_layers = n_gpu_layers
|
| 21 |
+
self._llm = None
|
| 22 |
+
model_name = model_path.split("/")[-1].split(".")[0]
|
| 23 |
+
self.models = [
|
| 24 |
+
BackendModel(
|
| 25 |
+
name=model_name,
|
| 26 |
+
family=_family(model_name),
|
| 27 |
+
context_length=n_ctx,
|
| 28 |
+
requires_internet=False,
|
| 29 |
+
)
|
| 30 |
+
]
|
| 31 |
+
|
| 32 |
+
def is_available(self) -> bool:
|
| 33 |
+
try:
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
|
| 36 |
+
import llama_cpp
|
| 37 |
+
|
| 38 |
+
return Path(self._model_path).exists()
|
| 39 |
+
except ImportError:
|
| 40 |
+
return False
|
| 41 |
+
|
| 42 |
+
async def warm(self) -> None:
|
| 43 |
+
if not self.is_available():
|
| 44 |
+
return
|
| 45 |
+
import asyncio
|
| 46 |
+
|
| 47 |
+
loop = asyncio.get_event_loop()
|
| 48 |
+
await loop.run_in_executor(None, self._load_model)
|
| 49 |
+
|
| 50 |
+
def _load_model(self) -> None:
|
| 51 |
+
from llama_cpp import Llama
|
| 52 |
+
|
| 53 |
+
self._llm = Llama(
|
| 54 |
+
model_path=self._model_path,
|
| 55 |
+
n_ctx=self._n_ctx,
|
| 56 |
+
n_gpu_layers=self._n_gpu_layers,
|
| 57 |
+
verbose=False,
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
async def chat(
|
| 61 |
+
self,
|
| 62 |
+
messages: list[dict],
|
| 63 |
+
*,
|
| 64 |
+
model: str = "",
|
| 65 |
+
stream: bool = False,
|
| 66 |
+
temperature: float = 0.7,
|
| 67 |
+
max_tokens: int = 1024,
|
| 68 |
+
**kwargs,
|
| 69 |
+
):
|
| 70 |
+
import asyncio
|
| 71 |
+
import time
|
| 72 |
+
|
| 73 |
+
if self._llm is None:
|
| 74 |
+
await self.warm()
|
| 75 |
+
if self._llm is None:
|
| 76 |
+
raise RuntimeError("llama.cpp model not loaded")
|
| 77 |
+
t0 = time.monotonic()
|
| 78 |
+
loop = asyncio.get_event_loop()
|
| 79 |
+
if not stream:
|
| 80 |
+
result = await loop.run_in_executor(
|
| 81 |
+
None,
|
| 82 |
+
lambda: self._llm.create_chat_completion(
|
| 83 |
+
messages=messages,
|
| 84 |
+
temperature=temperature,
|
| 85 |
+
max_tokens=max_tokens,
|
| 86 |
+
),
|
| 87 |
+
)
|
| 88 |
+
text = result["choices"][0]["message"]["content"]
|
| 89 |
+
ms = int((time.monotonic() - t0) * 1000)
|
| 90 |
+
return ChatResult(
|
| 91 |
+
text=text,
|
| 92 |
+
tokens_in=result["usage"]["prompt_tokens"],
|
| 93 |
+
tokens_out=result["usage"]["completion_tokens"],
|
| 94 |
+
model=self.models[0].name,
|
| 95 |
+
ms=ms,
|
| 96 |
+
)
|
| 97 |
+
else:
|
| 98 |
+
return self._stream_chat(messages, temperature, max_tokens)
|
| 99 |
+
|
| 100 |
+
async def _stream_chat(self, messages, temperature, max_tokens):
|
| 101 |
+
import asyncio
|
| 102 |
+
|
| 103 |
+
loop = asyncio.get_event_loop()
|
| 104 |
+
result = await loop.run_in_executor(
|
| 105 |
+
None,
|
| 106 |
+
lambda: self._llm.create_chat_completion(
|
| 107 |
+
messages=messages,
|
| 108 |
+
temperature=temperature,
|
| 109 |
+
max_tokens=max_tokens,
|
| 110 |
+
stream=True,
|
| 111 |
+
),
|
| 112 |
+
)
|
| 113 |
+
for chunk in result:
|
| 114 |
+
delta = chunk["choices"][0].get("delta", {})
|
| 115 |
+
text = delta.get("content", "")
|
| 116 |
+
done = chunk["choices"][0]["finish_reason"] is not None
|
| 117 |
+
if text or done:
|
| 118 |
+
yield Token(text=text, stop=done)
|
| 119 |
+
|
| 120 |
+
async def complete(
|
| 121 |
+
self, prompt: str, *, model: str = "", stream: bool = False, **kwargs
|
| 122 |
+
):
|
| 123 |
+
messages = [{"role": "user", "content": prompt}]
|
| 124 |
+
return await self.chat(messages, model=model, stream=stream, **kwargs)
|
| 125 |
+
|
| 126 |
+
async def close(self) -> None:
|
| 127 |
+
self._llm = None
|
| 128 |
+
|
| 129 |
+
def health(self) -> dict:
|
| 130 |
+
return {
|
| 131 |
+
"backend": "llama_cpp",
|
| 132 |
+
"model_path": self._model_path,
|
| 133 |
+
"loaded": self._llm is not None,
|
| 134 |
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ollama HTTP backend: http://localhost:11434"""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from hearthnet.services.llm.backends.base import BackendModel, ChatResult, Token
|
| 5 |
+
from hearthnet.services.llm.tokenizers import model_family
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _family(model_name: str) -> str:
|
| 9 |
+
return model_family(model_name)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class OllamaBackend:
|
| 13 |
+
name = "ollama"
|
| 14 |
+
|
| 15 |
+
def __init__(self, base_url: str = "http://localhost:11434", default_model: str = "") -> None:
|
| 16 |
+
self._base_url = base_url.rstrip("/")
|
| 17 |
+
self._default_model = default_model
|
| 18 |
+
self.models: list[BackendModel] = []
|
| 19 |
+
|
| 20 |
+
def is_available(self) -> bool:
|
| 21 |
+
try:
|
| 22 |
+
import httpx
|
| 23 |
+
|
| 24 |
+
resp = httpx.get(f"{self._base_url}/api/tags", timeout=3.0)
|
| 25 |
+
return resp.status_code == 200
|
| 26 |
+
except Exception:
|
| 27 |
+
return False
|
| 28 |
+
|
| 29 |
+
async def _list_models(self) -> list[str]:
|
| 30 |
+
try:
|
| 31 |
+
import httpx
|
| 32 |
+
|
| 33 |
+
async with httpx.AsyncClient() as client:
|
| 34 |
+
resp = await client.get(f"{self._base_url}/api/tags", timeout=5.0)
|
| 35 |
+
data = resp.json()
|
| 36 |
+
return [m["name"] for m in data.get("models", [])]
|
| 37 |
+
except Exception:
|
| 38 |
+
return []
|
| 39 |
+
|
| 40 |
+
async def warm(self) -> None:
|
| 41 |
+
model_names = await self._list_models()
|
| 42 |
+
self.models = [
|
| 43 |
+
BackendModel(
|
| 44 |
+
name=m,
|
| 45 |
+
family=_family(m),
|
| 46 |
+
context_length=4096,
|
| 47 |
+
requires_internet=False,
|
| 48 |
+
)
|
| 49 |
+
for m in model_names
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
async def chat(
|
| 53 |
+
self,
|
| 54 |
+
messages: list[dict],
|
| 55 |
+
*,
|
| 56 |
+
model: str,
|
| 57 |
+
stream: bool = False,
|
| 58 |
+
temperature: float = 0.7,
|
| 59 |
+
max_tokens: int = 1024,
|
| 60 |
+
**kwargs,
|
| 61 |
+
):
|
| 62 |
+
import time
|
| 63 |
+
|
| 64 |
+
import httpx
|
| 65 |
+
|
| 66 |
+
model = model or self._default_model
|
| 67 |
+
t0 = time.monotonic()
|
| 68 |
+
|
| 69 |
+
payload = {
|
| 70 |
+
"model": model,
|
| 71 |
+
"messages": messages,
|
| 72 |
+
"stream": stream,
|
| 73 |
+
"options": {"temperature": temperature, "num_predict": max_tokens},
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
if not stream:
|
| 77 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 78 |
+
resp = await client.post(f"{self._base_url}/api/chat", json=payload)
|
| 79 |
+
resp.raise_for_status()
|
| 80 |
+
data = resp.json()
|
| 81 |
+
text = data.get("message", {}).get("content", "")
|
| 82 |
+
ms = int((time.monotonic() - t0) * 1000)
|
| 83 |
+
return ChatResult(
|
| 84 |
+
text=text,
|
| 85 |
+
tokens_in=0,
|
| 86 |
+
tokens_out=len(text.split()),
|
| 87 |
+
model=model,
|
| 88 |
+
ms=ms,
|
| 89 |
+
)
|
| 90 |
+
else:
|
| 91 |
+
return self._stream_chat(payload, t0)
|
| 92 |
+
|
| 93 |
+
async def _stream_chat(self, payload: dict, t0: float):
|
| 94 |
+
import json
|
| 95 |
+
|
| 96 |
+
import httpx
|
| 97 |
+
|
| 98 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 99 |
+
async with client.stream(
|
| 100 |
+
"POST", f"{self._base_url}/api/chat", json=payload
|
| 101 |
+
) as resp:
|
| 102 |
+
async for line in resp.aiter_lines():
|
| 103 |
+
if line:
|
| 104 |
+
try:
|
| 105 |
+
data = json.loads(line)
|
| 106 |
+
text = data.get("message", {}).get("content", "")
|
| 107 |
+
done = data.get("done", False)
|
| 108 |
+
if text:
|
| 109 |
+
yield Token(text=text, stop=done)
|
| 110 |
+
except json.JSONDecodeError:
|
| 111 |
+
pass
|
| 112 |
+
|
| 113 |
+
async def complete(
|
| 114 |
+
self, prompt: str, *, model: str, stream: bool = False, **kwargs
|
| 115 |
+
):
|
| 116 |
+
messages = [{"role": "user", "content": prompt}]
|
| 117 |
+
return await self.chat(messages, model=model, stream=stream, **kwargs)
|
| 118 |
+
|
| 119 |
+
async def close(self) -> None:
|
| 120 |
+
pass
|
| 121 |
+
|
| 122 |
+
def health(self) -> dict:
|
| 123 |
+
return {
|
| 124 |
+
"backend": "ollama",
|
| 125 |
+
"available": self.is_available(),
|
| 126 |
+
"url": self._base_url,
|
| 127 |
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI-compatible HTTP backend. ONLINE ONLY — opt-in fallback."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from hearthnet.services.llm.backends.base import BackendModel, ChatResult, Token
|
| 5 |
+
from hearthnet.services.llm.tokenizers import model_family
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _family(model_name: str) -> str:
|
| 9 |
+
return model_family(model_name)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class OpenAICompatBackend:
|
| 13 |
+
"""OpenAI-compatible HTTP backend. Only used when explicitly configured AND online.
|
| 14 |
+
Never the default local path."""
|
| 15 |
+
|
| 16 |
+
name = "openai_compat"
|
| 17 |
+
|
| 18 |
+
def __init__(
|
| 19 |
+
self,
|
| 20 |
+
base_url: str = "https://api.openai.com/v1",
|
| 21 |
+
api_key_env: str = "OPENAI_API_KEY",
|
| 22 |
+
model: str = "gpt-3.5-turbo",
|
| 23 |
+
) -> None:
|
| 24 |
+
self._base_url = base_url
|
| 25 |
+
self._api_key_env = api_key_env
|
| 26 |
+
self._model = model
|
| 27 |
+
self.models = [
|
| 28 |
+
BackendModel(
|
| 29 |
+
name=model,
|
| 30 |
+
family="gpt",
|
| 31 |
+
context_length=16385,
|
| 32 |
+
requires_internet=True,
|
| 33 |
+
)
|
| 34 |
+
]
|
| 35 |
+
|
| 36 |
+
def _get_key(self) -> str:
|
| 37 |
+
import os
|
| 38 |
+
|
| 39 |
+
key = os.environ.get(self._api_key_env, "")
|
| 40 |
+
if not key:
|
| 41 |
+
raise RuntimeError(f"API key env {self._api_key_env} not set")
|
| 42 |
+
return key
|
| 43 |
+
|
| 44 |
+
def is_available(self) -> bool:
|
| 45 |
+
import os
|
| 46 |
+
|
| 47 |
+
return bool(os.environ.get(self._api_key_env))
|
| 48 |
+
|
| 49 |
+
async def warm(self) -> None:
|
| 50 |
+
pass
|
| 51 |
+
|
| 52 |
+
async def chat(
|
| 53 |
+
self,
|
| 54 |
+
messages: list[dict],
|
| 55 |
+
*,
|
| 56 |
+
model: str = "",
|
| 57 |
+
stream: bool = False,
|
| 58 |
+
temperature: float = 0.7,
|
| 59 |
+
max_tokens: int = 1024,
|
| 60 |
+
**kwargs,
|
| 61 |
+
):
|
| 62 |
+
import time
|
| 63 |
+
|
| 64 |
+
import httpx
|
| 65 |
+
|
| 66 |
+
model = model or self._model
|
| 67 |
+
t0 = time.monotonic()
|
| 68 |
+
payload = {
|
| 69 |
+
"model": model,
|
| 70 |
+
"messages": messages,
|
| 71 |
+
"temperature": temperature,
|
| 72 |
+
"max_tokens": max_tokens,
|
| 73 |
+
"stream": stream,
|
| 74 |
+
}
|
| 75 |
+
headers = {
|
| 76 |
+
"Authorization": f"Bearer {self._get_key()}",
|
| 77 |
+
"Content-Type": "application/json",
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
if not stream:
|
| 81 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 82 |
+
resp = await client.post(
|
| 83 |
+
f"{self._base_url}/chat/completions",
|
| 84 |
+
json=payload,
|
| 85 |
+
headers=headers,
|
| 86 |
+
)
|
| 87 |
+
resp.raise_for_status()
|
| 88 |
+
data = resp.json()
|
| 89 |
+
text = data["choices"][0]["message"]["content"]
|
| 90 |
+
ms = int((time.monotonic() - t0) * 1000)
|
| 91 |
+
usage = data.get("usage", {})
|
| 92 |
+
return ChatResult(
|
| 93 |
+
text=text,
|
| 94 |
+
tokens_in=usage.get("prompt_tokens", 0),
|
| 95 |
+
tokens_out=usage.get("completion_tokens", 0),
|
| 96 |
+
model=model,
|
| 97 |
+
ms=ms,
|
| 98 |
+
)
|
| 99 |
+
else:
|
| 100 |
+
return self._stream_chat(payload, headers, model, t0)
|
| 101 |
+
|
| 102 |
+
async def _stream_chat(self, payload, headers, model, t0):
|
| 103 |
+
import json
|
| 104 |
+
|
| 105 |
+
import httpx
|
| 106 |
+
|
| 107 |
+
payload["stream"] = True
|
| 108 |
+
async with httpx.AsyncClient(timeout=60.0) as client:
|
| 109 |
+
async with client.stream(
|
| 110 |
+
"POST",
|
| 111 |
+
f"{self._base_url}/chat/completions",
|
| 112 |
+
json=payload,
|
| 113 |
+
headers=headers,
|
| 114 |
+
) as resp:
|
| 115 |
+
async for line in resp.aiter_lines():
|
| 116 |
+
if line.startswith("data: "):
|
| 117 |
+
raw = line[6:]
|
| 118 |
+
if raw == "[DONE]":
|
| 119 |
+
yield Token(text="", stop=True)
|
| 120 |
+
return
|
| 121 |
+
try:
|
| 122 |
+
data = json.loads(raw)
|
| 123 |
+
delta = data["choices"][0].get("delta", {})
|
| 124 |
+
text = delta.get("content", "")
|
| 125 |
+
if text:
|
| 126 |
+
yield Token(text=text, stop=False)
|
| 127 |
+
except Exception:
|
| 128 |
+
pass
|
| 129 |
+
|
| 130 |
+
async def complete(
|
| 131 |
+
self, prompt: str, *, model: str = "", stream: bool = False, **kwargs
|
| 132 |
+
):
|
| 133 |
+
return await self.chat(
|
| 134 |
+
[{"role": "user", "content": prompt}], model=model, stream=stream, **kwargs
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
async def close(self) -> None:
|
| 138 |
+
pass
|
| 139 |
+
|
| 140 |
+
def health(self) -> dict:
|
| 141 |
+
return {
|
| 142 |
+
"backend": "openai_compat",
|
| 143 |
+
"available": self.is_available(),
|
| 144 |
+
"url": self._base_url,
|
| 145 |
+
}
|