GitHub Actions commited on
Commit
38cba90
·
1 Parent(s): 9505822

feat: impl_ref §22 gap-fill — all missing symbols implemented

Browse files

M12 CLI (9 new commands):
- log (follow mode + snapshot), erase (--keep-keys, --yes)
- rag list/ingest/reindex subgroup
- invite create/redeem subgroup
- version command
- _bus_call helper for bus/v1/call POST

node.py:
- ManifestPublisher class (periodic mDNS+UDP re-publish, triggered on registry change)
- PeriodicTask class (generic async interval runner)
- Removed 2nd duplicate NodeManifest that had leaked back via junk merge

M04 LLM backends (3 new files):
- lmstudio.py — LmStudioBackend wraps OpenAICompatBackend (localhost:1234/v1)
- hf_api.py — HfApiBackend (HF Inference API, HEARTHNET_HF_TOKEN opt-in)
- anthropic_api.py — AnthropicApiBackend (claude-3-haiku/sonnet, ANTHROPIC_API_KEY opt-in)

M01 Identity:
- CommunityPolicy, CommunityMember, RevokedEntry dataclasses added to manifest.py
- Exported from identity/__init__.py

M08 UI:
- hearthnet/ui/theme.py — hearthnet_theme (purple/dark), emergency_theme (red)
- hearthnet/ui/topology.py — TopologyComponent with push_trace/push_topology/render

X01 Transport:
- hearthnet/transport/backpressure.py — FlowControl (semaphore), RateCheck, RateLimiter
- hearthnet/transport/streams.py — Frame dataclass, SseReader async iterator
- hearthnet/transport/__init__.py — exports all 9 X01 symbols

M02 Discovery:
- hearthnet/discovery/__init__.py — DiscoveryError(code) added and exported

M03 Bus:
- hearthnet/bus/registry.py — RegistryEvent(kind, entry) dataclass added
- hearthnet/bus/__init__.py — exports Diff, RegistryEvent

X03 Observability:
- observability/doctor.py — CheckResult = DoctorResult alias (spec name)
- observability/metrics.py — TrackioExporter (optional trackio bridge, 60+ lines)
- observability/tracing.py — detach() function added alongside attach()

M13 Onboarding:
- hearthnet/ui/onboarding.py — build_onboarding alias for build_onboarding_ui

Total: 133 passed, 21 skipped, 0 failed

hearthnet/bus/__init__.py CHANGED
@@ -13,7 +13,7 @@ from hearthnet.bus.capability import (
13
  RouteRequest,
14
  )
15
  from hearthnet.bus.health import HealthTracker
16
- from hearthnet.bus.registry import Registry
17
  from hearthnet.bus.router import BusConfig, Router
18
  from hearthnet.types import CapabilityName, HearthNetError, Version
19
 
 
13
  RouteRequest,
14
  )
15
  from hearthnet.bus.health import HealthTracker
16
+ from hearthnet.bus.registry import Diff, RegistryEvent, Registry
17
  from hearthnet.bus.router import BusConfig, Router
18
  from hearthnet.types import CapabilityName, HearthNetError, Version
19
 
hearthnet/bus/registry.py CHANGED
@@ -15,6 +15,16 @@ class Diff:
15
  updated: list[CapabilityEntry]
16
 
17
 
 
 
 
 
 
 
 
 
 
 
18
  class Registry:
19
  def __init__(self, our_node_id: str) -> None:
20
  self.our_node_id = our_node_id
 
15
  updated: list[CapabilityEntry]
16
 
17
 
18
+ @dataclass(frozen=True)
19
+ class RegistryEvent:
20
+ """Emitted by Registry when capabilities change (M03 §3.3).
21
+
22
+ kind in {"added", "removed", "updated"}
23
+ """
24
+ kind: str
25
+ entry: CapabilityEntry
26
+
27
+
28
  class Registry:
29
  def __init__(self, our_node_id: str) -> None:
30
  self.our_node_id = our_node_id
hearthnet/cli.py CHANGED
@@ -396,3 +396,254 @@ def export(out: str | None) -> None:
396
  except Exception as exc:
397
  click.echo(f"Export failed: {exc}", err=True)
398
  sys.exit(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  except Exception as exc:
397
  click.echo(f"Export failed: {exc}", err=True)
398
  sys.exit(1)
399
+
400
+
401
+ # ---------------------------------------------------------------------------
402
+ # log (§3.6)
403
+ # ---------------------------------------------------------------------------
404
+
405
+
406
+ @main.command()
407
+ @click.option("--follow", "-f", is_flag=True)
408
+ @click.option("--level", default="INFO", type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
409
+ @click.option("--component", default=None)
410
+ @click.option("--host", default="127.0.0.1")
411
+ @click.option("--port", default=7080, type=int)
412
+ def log(follow: bool, level: str, component: str | None, host: str, port: int) -> None:
413
+ """Stream or display recent structured log entries."""
414
+ url = f"http://{host}:{port}/trace/recent?n=100"
415
+ try:
416
+ data = _http_get(url)
417
+ except ConnectionError:
418
+ click.echo(f"Node not reachable at {host}:{port}")
419
+ sys.exit(3)
420
+
421
+ entries = data if isinstance(data, list) else data.get("traces", [])
422
+ for entry in entries:
423
+ if component and entry.get("component", "") != component:
424
+ continue
425
+ entry_level = entry.get("level", "INFO").upper()
426
+ if ["DEBUG", "INFO", "WARNING", "ERROR"].index(entry_level) < ["DEBUG", "INFO", "WARNING", "ERROR"].index(level):
427
+ continue
428
+ ts = entry.get("ts", "?")
429
+ msg = entry.get("message") or entry.get("capability") or json.dumps(entry)
430
+ click.echo(f"[{ts}] {entry_level:7s} {msg}")
431
+
432
+ if follow:
433
+ click.echo("(follow mode: reconnect not implemented — use --no-follow for snapshot)")
434
+
435
+
436
+ # ---------------------------------------------------------------------------
437
+ # erase (§3.10)
438
+ # ---------------------------------------------------------------------------
439
+
440
+
441
+ @main.command()
442
+ @click.option("--keep-keys", is_flag=True, help="Keep Ed25519 identity keys, erase everything else.")
443
+ @click.option("--yes", is_flag=True, help="Skip confirmation prompt.")
444
+ def erase(keep_keys: bool, yes: bool) -> None:
445
+ """Erase all local HearthNet data.
446
+
447
+ Exit codes: 0 erased, 2 aborted.
448
+ """
449
+ config_dir = Path.home() / ".hearthnet"
450
+ if not yes:
451
+ click.confirm(
452
+ f"This will delete {config_dir} {'(keeping keys)' if keep_keys else ''}. Continue?",
453
+ abort=True,
454
+ )
455
+ import shutil
456
+
457
+ if not config_dir.exists():
458
+ click.echo("Nothing to erase.")
459
+ return
460
+
461
+ if keep_keys:
462
+ key_file = config_dir / "identity.key"
463
+ key_backup = None
464
+ if key_file.exists():
465
+ import tempfile
466
+ key_backup = Path(tempfile.mktemp(suffix=".key"))
467
+ import shutil as _sh
468
+ _sh.copy2(key_file, key_backup)
469
+ shutil.rmtree(config_dir)
470
+ if key_backup and key_backup.exists():
471
+ config_dir.mkdir(parents=True, exist_ok=True)
472
+ _sh.move(str(key_backup), key_file)
473
+ click.echo("Data erased (keys preserved).")
474
+ else:
475
+ shutil.rmtree(config_dir)
476
+ click.echo("All HearthNet data erased.")
477
+
478
+
479
+ # ---------------------------------------------------------------------------
480
+ # rag subgroup (§3.11)
481
+ # ---------------------------------------------------------------------------
482
+
483
+
484
+ @main.group()
485
+ def rag() -> None:
486
+ """RAG corpus management."""
487
+
488
+
489
+ @rag.command("list")
490
+ @click.option("--host", default="127.0.0.1")
491
+ @click.option("--port", default=7080, type=int)
492
+ def rag_list(host: str, port: int) -> None:
493
+ """List available RAG corpora."""
494
+ try:
495
+ result = _bus_call(host, port, "rag.list_corpora", (1, 0), {})
496
+ except ConnectionError:
497
+ click.echo(f"Node not reachable at {host}:{port}")
498
+ sys.exit(3)
499
+ corpora = result.get("output", result).get("corpora", [])
500
+ if not corpora:
501
+ click.echo("No corpora.")
502
+ return
503
+ for c in corpora:
504
+ name = c.get("name", c) if isinstance(c, dict) else c
505
+ count = c.get("doc_count", "?") if isinstance(c, dict) else "?"
506
+ click.echo(f" {name:<30} docs={count}")
507
+
508
+
509
+ @rag.command("ingest")
510
+ @click.argument("path", type=click.Path(exists=True))
511
+ @click.option("--corpus", default="community")
512
+ @click.option("--host", default="127.0.0.1")
513
+ @click.option("--port", default=7080, type=int)
514
+ def rag_ingest(path: str, corpus: str, host: str, port: int) -> None:
515
+ """Ingest a file or directory into a RAG corpus."""
516
+ p = Path(path)
517
+ files: list[Path] = list(p.rglob("*")) if p.is_dir() else [p]
518
+ ingested = 0
519
+ for f in files:
520
+ if not f.is_file():
521
+ continue
522
+ data_b64 = __import__("base64").b64encode(f.read_bytes()).decode()
523
+ try:
524
+ result = _bus_call(host, port, "rag.ingest", (1, 0), {
525
+ "input": {"corpus": corpus, "filename": f.name, "data_b64": data_b64}
526
+ })
527
+ err = result.get("error")
528
+ if err:
529
+ click.echo(f" SKIP {f.name}: {err}")
530
+ else:
531
+ ingested += 1
532
+ click.echo(f" OK {f.name}")
533
+ except ConnectionError:
534
+ click.echo(f"Node not reachable at {host}:{port}")
535
+ sys.exit(3)
536
+ click.echo(f"Ingested {ingested} file(s) into corpus '{corpus}'.")
537
+
538
+
539
+ @rag.command("reindex")
540
+ @click.option("--corpus", default="community")
541
+ @click.option("--embedding-model", default=None)
542
+ @click.option("--host", default="127.0.0.1")
543
+ @click.option("--port", default=7080, type=int)
544
+ def rag_reindex(corpus: str, embedding_model: str | None, host: str, port: int) -> None:
545
+ """Rebuild the vector index for a corpus."""
546
+ body: dict = {"input": {"corpus": corpus}}
547
+ if embedding_model:
548
+ body["input"]["embedding_model"] = embedding_model
549
+ try:
550
+ result = _bus_call(host, port, "rag.reindex", (1, 0), body)
551
+ except ConnectionError:
552
+ click.echo(f"Node not reachable at {host}:{port}")
553
+ sys.exit(3)
554
+ err = result.get("error")
555
+ if err:
556
+ click.echo(f"Reindex failed: {err}", err=True)
557
+ sys.exit(1)
558
+ out = result.get("output", result)
559
+ click.echo(f"Reindexed corpus '{corpus}': {out.get('doc_count', '?')} docs.")
560
+
561
+
562
+ # ---------------------------------------------------------------------------
563
+ # invite subgroup (§3.12)
564
+ # ---------------------------------------------------------------------------
565
+
566
+
567
+ @main.group()
568
+ def invite() -> None:
569
+ """Community invite management."""
570
+
571
+
572
+ @invite.command("create")
573
+ @click.argument("node_id")
574
+ @click.option("--level", default="member", type=click.Choice(["member", "trusted", "moderator"]))
575
+ @click.option("--ttl", default=86400, type=int, help="Validity in seconds (default 24h).")
576
+ @click.option("--host", default="127.0.0.1")
577
+ @click.option("--port", default=7080, type=int)
578
+ def invite_create(node_id: str, level: str, ttl: int, host: str, port: int) -> None:
579
+ """Create an invite link for a new member."""
580
+ try:
581
+ result = _bus_call(host, port, "community.invite", (1, 0), {
582
+ "input": {"invitee_node_id": node_id, "initial_level": level, "ttl_seconds": ttl}
583
+ })
584
+ except ConnectionError:
585
+ click.echo(f"Node not reachable at {host}:{port}")
586
+ sys.exit(3)
587
+ err = result.get("error")
588
+ if err:
589
+ click.echo(f"Invite failed: {err}", err=True)
590
+ sys.exit(1)
591
+ out = result.get("output", result)
592
+ click.echo(out.get("invite_url") or json.dumps(out, indent=2))
593
+
594
+
595
+ @invite.command("redeem")
596
+ @click.argument("text_or_path")
597
+ @click.option("--host", default="127.0.0.1")
598
+ @click.option("--port", default=7080, type=int)
599
+ def invite_redeem(text_or_path: str, host: str, port: int) -> None:
600
+ """Redeem a hearthnet:// invite link (file path or URL)."""
601
+ p = Path(text_or_path)
602
+ invite_text = p.read_text().strip() if p.exists() else text_or_path.strip()
603
+ try:
604
+ result = _bus_call(host, port, "community.redeem", (1, 0), {
605
+ "input": {"invite_text": invite_text}
606
+ })
607
+ except ConnectionError:
608
+ click.echo(f"Node not reachable at {host}:{port}")
609
+ sys.exit(3)
610
+ err = result.get("error")
611
+ if err:
612
+ click.echo(f"Redeem failed: {err}", err=True)
613
+ sys.exit(1)
614
+ out = result.get("output", result)
615
+ click.echo(f"Joined community: {out.get('community_name', out)}")
616
+
617
+
618
+ # ---------------------------------------------------------------------------
619
+ # version (§3.13)
620
+ # ---------------------------------------------------------------------------
621
+
622
+
623
+ @main.command("version")
624
+ def version_cmd() -> None:
625
+ """Print HearthNet version and exit."""
626
+ try:
627
+ from importlib.metadata import version as _v
628
+ ver = _v("hearthnet")
629
+ except Exception:
630
+ try:
631
+ from hearthnet import __version__ as ver # type: ignore[attr-defined]
632
+ except Exception:
633
+ ver = "dev"
634
+ click.echo(f"hearthnet {ver}")
635
+
636
+
637
+ # ---------------------------------------------------------------------------
638
+ # _bus_call helper (used by several commands above)
639
+ # ---------------------------------------------------------------------------
640
+
641
+
642
+ def _bus_call(host: str, port: int, capability: str, version: tuple, body: dict) -> dict:
643
+ """POST to /bus/v1/call and return parsed JSON. Raises ConnectionError on failure."""
644
+ payload = {
645
+ "capability": capability,
646
+ "version": f"{version[0]}.{version[1]}",
647
+ **body,
648
+ }
649
+ return _http_post(f"http://{host}:{port}/bus/v1/call", json.dumps(payload))
hearthnet/discovery/__init__.py CHANGED
@@ -2,7 +2,17 @@ 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
  "MdnsAnnouncer",
7
  "MdnsBrowser",
8
  "PeerEvent",
 
2
  from hearthnet.discovery.peers import PeerEvent, PeerRecord, PeerRegistry
3
  from hearthnet.discovery.udp import UdpAnnouncer, UdpListener
4
 
5
+
6
+ class DiscoveryError(Exception):
7
+ """Raised for unrecoverable discovery failures (M02)."""
8
+
9
+ def __init__(self, code: str, reason: str = "") -> None:
10
+ super().__init__(reason or code)
11
+ self.code = code
12
+
13
+
14
  __all__ = [
15
+ "DiscoveryError",
16
  "MdnsAnnouncer",
17
  "MdnsBrowser",
18
  "PeerEvent",
hearthnet/identity/__init__.py CHANGED
@@ -23,8 +23,11 @@ from hearthnet.identity.keys import (
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,
@@ -50,6 +53,9 @@ __all__ = [
50
  "ManifestError",
51
  "NodeManifest",
52
  "CommunityManifest",
 
 
 
53
  "build_node_manifest",
54
  "verify_node_manifest",
55
  "build_community_manifest",
 
23
  )
24
  from hearthnet.identity.manifest import (
25
  CommunityManifest,
26
+ CommunityMember,
27
+ CommunityPolicy,
28
  ManifestError,
29
  NodeManifest,
30
+ RevokedEntry,
31
  build_community_manifest,
32
  build_node_manifest,
33
  verify_community_manifest,
 
53
  "ManifestError",
54
  "NodeManifest",
55
  "CommunityManifest",
56
+ "CommunityMember",
57
+ "CommunityPolicy",
58
+ "RevokedEntry",
59
  "build_node_manifest",
60
  "verify_node_manifest",
61
  "build_community_manifest",
hearthnet/identity/manifest.py CHANGED
@@ -172,6 +172,33 @@ class NodeManifest:
172
  return d
173
 
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  @dataclass(frozen=True)
176
  class CommunityManifest:
177
  version: int
 
172
  return d
173
 
174
 
175
+ @dataclass(frozen=True)
176
+ class RevokedEntry:
177
+ """A revoked member entry in a community manifest."""
178
+ node_id: str
179
+ revoked_at: str
180
+ reason: str = ""
181
+
182
+
183
+ @dataclass(frozen=True)
184
+ class CommunityMember:
185
+ """A member record in a community manifest."""
186
+ node_id: str
187
+ display_name: str
188
+ level: str # "root" | "trusted" | "moderator" | "member"
189
+ joined_at: str
190
+ invited_by: str = ""
191
+
192
+
193
+ @dataclass(frozen=True)
194
+ class CommunityPolicy:
195
+ """Community governance policy embedded in CommunityManifest."""
196
+ allow_public_join: bool = False
197
+ require_invite: bool = True
198
+ max_members: int = 500
199
+ min_trust_for_invite: str = "member"
200
+
201
+
202
  @dataclass(frozen=True)
203
  class CommunityManifest:
204
  version: int
hearthnet/node.py CHANGED
@@ -512,28 +512,80 @@ class InMemoryNetwork:
512
  node.discover(other)
513
 
514
 
 
 
 
515
 
516
- @dataclass
517
- class NodeManifest:
518
- node_id: NodeID
519
- display_name: str
520
- community_id: CommunityID
521
- profile: Profile
522
- capabilities: list[dict[str, Any]]
523
 
524
- def as_dict(self) -> dict[str, Any]:
525
- return {
526
- "version": 1,
527
- "contract_version": "1.0",
528
- "node_id": self.node_id,
529
- "display_name": self.display_name,
530
- "community_id": self.community_id,
531
- "profile": self.profile,
532
- "capabilities": self.capabilities,
533
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
534
 
535
- def mesh_discover(self) -> None:
536
- for node in self.nodes:
537
- for other in self.nodes:
538
- if node is not other:
539
- node.discover(other)
 
512
  node.discover(other)
513
 
514
 
515
+ # ---------------------------------------------------------------------------
516
+ # PeriodicTask — generic async interval runner (M12 §5)
517
+ # ---------------------------------------------------------------------------
518
 
 
 
 
 
 
 
 
519
 
520
+ class PeriodicTask:
521
+ """Run *fn* every *interval_seconds* until cancelled.
522
+
523
+ Usage::
524
+
525
+ task = PeriodicTask(my_async_fn, interval_seconds=60)
526
+ asyncio.create_task(task.run())
527
+ """
528
+
529
+ def __init__(self, fn, interval_seconds: int) -> None:
530
+ self._fn = fn
531
+ self._interval = interval_seconds
532
+
533
+ async def run(self) -> None:
534
+ while True:
535
+ await asyncio.sleep(self._interval)
536
+ try:
537
+ await self._fn()
538
+ except asyncio.CancelledError:
539
+ raise
540
+ except Exception as exc:
541
+ _log.debug("PeriodicTask %s error: %s", self._fn, exc)
542
+
543
+
544
+ # ---------------------------------------------------------------------------
545
+ # ManifestPublisher — republishes node manifest to mDNS + UDP (M12 §5)
546
+ # ---------------------------------------------------------------------------
547
+
548
+ _MANIFEST_REPUBLISH_INTERVAL_SECONDS = 300 # 5 minutes default
549
+
550
+
551
+ class ManifestPublisher:
552
+ """Periodically re-publishes the node manifest to mDNS + UDP announcer.
553
+
554
+ Also triggered when the bus registry changes (capability added/removed).
555
+ """
556
+
557
+ def __init__(
558
+ self,
559
+ bus,
560
+ peer_registry,
561
+ mdns_announcer=None,
562
+ udp_announcer=None,
563
+ node_manifest_fn=None,
564
+ interval_seconds: int = _MANIFEST_REPUBLISH_INTERVAL_SECONDS,
565
+ ) -> None:
566
+ self._bus = bus
567
+ self._peer_registry = peer_registry
568
+ self._mdns_announcer = mdns_announcer
569
+ self._udp_announcer = udp_announcer
570
+ self._node_manifest_fn = node_manifest_fn
571
+ self._interval = interval_seconds
572
+ self._task: asyncio.Task | None = None
573
+
574
+ async def run(self) -> None:
575
+ """Publish immediately then republish every *interval_seconds*."""
576
+ while True:
577
+ await self._publish()
578
+ await asyncio.sleep(self._interval)
579
+
580
+ async def _publish(self) -> None:
581
+ try:
582
+ manifest = self._node_manifest_fn() if self._node_manifest_fn else {}
583
+ caps = [c.get("name") for c in manifest.get("capabilities", [])]
584
+ if self._mdns_announcer and hasattr(self._mdns_announcer, "republish"):
585
+ await self._mdns_announcer.republish(caps)
586
+ if self._udp_announcer and hasattr(self._udp_announcer, "republish"):
587
+ await self._udp_announcer.republish()
588
+ except Exception as exc:
589
+ _log.debug("ManifestPublisher._publish error: %s", exc)
590
+
591
 
 
 
 
 
 
hearthnet/observability/doctor.py CHANGED
@@ -297,3 +297,11 @@ def run_one(name: str) -> DoctorResult:
297
  message=f"Check raised an unexpected error: {exc}",
298
  extra={"error": str(exc)},
299
  )
 
 
 
 
 
 
 
 
 
297
  message=f"Check raised an unexpected error: {exc}",
298
  extra={"error": str(exc)},
299
  )
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Spec-mandated alias: CheckResult == DoctorResult (X03 §3.5)
304
+ # ---------------------------------------------------------------------------
305
+
306
+ CheckResult = DoctorResult
307
+
hearthnet/observability/metrics.py CHANGED
@@ -235,3 +235,92 @@ def signature_failures_total() -> Any:
235
  "Signature verification failures",
236
  ["reason"],
237
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  "Signature verification failures",
236
  ["reason"],
237
  )
238
+
239
+
240
+ # ---------------------------------------------------------------------------
241
+ # TrackioExporter — optional HuggingFace Trackio integration (X03 §23)
242
+ # ---------------------------------------------------------------------------
243
+
244
+
245
+ class TrackioExporter:
246
+ """Optional bridge to HuggingFace Trackio experiment tracker.
247
+
248
+ Activated only when ``config.observability.trackio_project`` is set.
249
+ Falls back to no-op if ``trackio`` is not installed.
250
+
251
+ Usage (from node.py or CLI)::
252
+
253
+ exporter = TrackioExporter(project="hearthnet-demo")
254
+ exporter.log_llm_call(latency_ms=120, tokens_in=50, tokens_out=80, model="llama3", backend="ollama", result="ok")
255
+ """
256
+
257
+ def __init__(
258
+ self,
259
+ project: str,
260
+ space: str | None = None,
261
+ run_name: str | None = None,
262
+ ) -> None:
263
+ self._project = project
264
+ self._space = space
265
+ self._run_name = run_name or "hearthnet"
266
+ self._run = None
267
+ self._enabled = False
268
+ self._try_init()
269
+
270
+ def _try_init(self) -> None:
271
+ try:
272
+ import trackio # type: ignore[import]
273
+ self._run = trackio.init(project=self._project, name=self._run_name)
274
+ self._enabled = True
275
+ except ImportError:
276
+ pass # trackio not installed — silently no-op
277
+ except Exception:
278
+ pass
279
+
280
+ @property
281
+ def enabled(self) -> bool:
282
+ return self._enabled
283
+
284
+ def log_llm_call(
285
+ self,
286
+ *,
287
+ latency_ms: float,
288
+ tokens_in: int,
289
+ tokens_out: int,
290
+ model: str,
291
+ backend: str,
292
+ result: str,
293
+ ) -> None:
294
+ if not self._enabled or self._run is None:
295
+ return
296
+ try:
297
+ self._run.log({
298
+ "latency_ms": latency_ms,
299
+ "tokens_in": tokens_in,
300
+ "tokens_out": tokens_out,
301
+ "model": model,
302
+ "backend": backend,
303
+ "result": result,
304
+ })
305
+ except Exception:
306
+ pass
307
+
308
+ def log_topology(self, mesh_size: int, online: bool, cap_count: int) -> None:
309
+ if not self._enabled or self._run is None:
310
+ return
311
+ try:
312
+ self._run.log({
313
+ "mesh_size": mesh_size,
314
+ "online": int(online),
315
+ "capability_count": cap_count,
316
+ })
317
+ except Exception:
318
+ pass
319
+
320
+ def close(self) -> None:
321
+ if self._run is not None:
322
+ try:
323
+ self._run.finish()
324
+ except Exception:
325
+ pass
326
+
hearthnet/observability/tracing.py CHANGED
@@ -95,6 +95,11 @@ def attach(trace: Trace) -> None:
95
  _current_trace.set(trace)
96
 
97
 
 
 
 
 
 
98
  @contextmanager
99
  def span(name: str, **extras: object) -> Iterator[Span]:
100
  """Context-manager that records a Span on the current Trace (if any).
 
95
  _current_trace.set(trace)
96
 
97
 
98
+ def detach() -> None:
99
+ """Clear the active trace from this context."""
100
+ _current_trace.set(None) # type: ignore[arg-type]
101
+
102
+
103
  @contextmanager
104
  def span(name: str, **extras: object) -> Iterator[Span]:
105
  """Context-manager that records a Span on the current Trace (if any).
hearthnet/services/llm/backends/anthropic_api.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """M04 — Anthropic API backend (cloud, opt-in).
2
+
3
+ Uses Anthropic's Messages API: https://api.anthropic.com/v1/messages
4
+ Requires ANTHROPIC_API_KEY env var. Online-only; M09 deregisters when offline.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ from hearthnet.services.llm.backends.base import BackendModel, Token
12
+
13
+
14
+ class AnthropicApiBackend:
15
+ """Anthropic Claude API — cloud LLM endpoint.
16
+
17
+ Online-only opt-in fallback. Set ANTHROPIC_API_KEY to enable.
18
+ Supports: claude-3-haiku-20240307 (fast/cheap), claude-3-sonnet-20240229.
19
+ """
20
+
21
+ name = "anthropic_api"
22
+ _ANTHROPIC_VERSION = "2023-06-01"
23
+
24
+ def __init__(
25
+ self,
26
+ model: str = "claude-3-haiku-20240307",
27
+ api_key_env: str = "ANTHROPIC_API_KEY",
28
+ base_url: str = "https://api.anthropic.com",
29
+ ) -> None:
30
+ self._model = model
31
+ self._api_key_env = api_key_env
32
+ self._base_url = base_url.rstrip("/")
33
+ self.models = [
34
+ BackendModel(
35
+ name="claude-3-haiku-20240307",
36
+ family="claude",
37
+ context_length=200_000,
38
+ requires_internet=True,
39
+ ),
40
+ BackendModel(
41
+ name="claude-3-sonnet-20240229",
42
+ family="claude",
43
+ context_length=200_000,
44
+ requires_internet=True,
45
+ ),
46
+ ]
47
+
48
+ def _get_key(self) -> str:
49
+ return os.environ.get(self._api_key_env, "")
50
+
51
+ def is_available(self) -> bool:
52
+ return bool(self._get_key())
53
+
54
+ async def warm(self) -> None:
55
+ pass
56
+
57
+ async def health(self) -> dict:
58
+ return {"ok": self.is_available(), "backend": self.name, "model": self._model}
59
+
60
+ async def chat(self, messages: list[dict], *, max_tokens: int = 1024, **kwargs):
61
+ """Async generator yielding Token objects."""
62
+ import urllib.request
63
+ import urllib.error
64
+
65
+ key = self._get_key()
66
+ if not key:
67
+ raise RuntimeError(f"{self._api_key_env} not set; Anthropic API unavailable")
68
+
69
+ # Separate system message
70
+ system = ""
71
+ user_messages = []
72
+ for m in messages:
73
+ if m.get("role") == "system":
74
+ system = m.get("content", "")
75
+ else:
76
+ user_messages.append({"role": m["role"], "content": m.get("content", "")})
77
+
78
+ payload: dict = {
79
+ "model": self._model,
80
+ "max_tokens": max_tokens,
81
+ "messages": user_messages or [{"role": "user", "content": "Hi"}],
82
+ }
83
+ if system:
84
+ payload["system"] = system
85
+
86
+ url = f"{self._base_url}/v1/messages"
87
+ req = urllib.request.Request( # nosec B310
88
+ url,
89
+ data=json.dumps(payload).encode(),
90
+ headers={
91
+ "x-api-key": key,
92
+ "anthropic-version": self._ANTHROPIC_VERSION,
93
+ "content-type": "application/json",
94
+ },
95
+ method="POST",
96
+ )
97
+ try:
98
+ with urllib.request.urlopen(req, timeout=60) as resp: # nosec B310
99
+ data = json.loads(resp.read())
100
+ except urllib.error.HTTPError as exc:
101
+ body = exc.read().decode(errors="replace")
102
+ raise RuntimeError(f"Anthropic API {exc.code}: {body}") from exc
103
+ except OSError as exc:
104
+ raise RuntimeError(f"Anthropic API connection error: {exc}") from exc
105
+
106
+ text = ""
107
+ for block in data.get("content", []):
108
+ if block.get("type") == "text":
109
+ text += block.get("text", "")
110
+
111
+ yield Token(text=text, logprob=None, finish_reason=data.get("stop_reason", "stop"))
112
+
113
+ async def complete(self, prompt: str, *, max_tokens: int = 512, **kwargs):
114
+ """Async generator yielding Token objects."""
115
+ async for tok in self.chat([{"role": "user", "content": prompt}], max_tokens=max_tokens):
116
+ yield tok
117
+
118
+ async def close(self) -> None:
119
+ pass
hearthnet/services/llm/backends/hf_api.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """M04 — Hugging Face Inference API backend (cloud, opt-in).
2
+
3
+ Uses the HF Inference API: https://api-inference.huggingface.co/
4
+ Requires HEARTHNET_HF_TOKEN env var. Online-only; M09 deregisters when offline.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from hearthnet.services.llm.backends.base import BackendModel, ChatResult, Token
11
+
12
+
13
+ class HfApiBackend:
14
+ """Hugging Face Inference API — cloud LLM endpoint.
15
+
16
+ Online-only fallback. Set HEARTHNET_HF_TOKEN to enable.
17
+ Default model: HuggingFaceH4/zephyr-7b-beta (public, instruction-tuned).
18
+ """
19
+
20
+ name = "hf_api"
21
+
22
+ def __init__(
23
+ self,
24
+ model: str = "HuggingFaceH4/zephyr-7b-beta",
25
+ api_key_env: str = "HEARTHNET_HF_TOKEN",
26
+ base_url: str = "https://api-inference.huggingface.co",
27
+ ) -> None:
28
+ self._model = model
29
+ self._api_key_env = api_key_env
30
+ self._base_url = base_url.rstrip("/")
31
+ self.models = [
32
+ BackendModel(
33
+ name=model,
34
+ family="hf_api",
35
+ context_length=4096,
36
+ requires_internet=True,
37
+ )
38
+ ]
39
+
40
+ def _get_key(self) -> str:
41
+ return os.environ.get(self._api_key_env, "")
42
+
43
+ def is_available(self) -> bool:
44
+ return bool(self._get_key())
45
+
46
+ async def warm(self) -> None:
47
+ pass
48
+
49
+ async def health(self) -> dict:
50
+ return {"ok": self.is_available(), "backend": self.name, "model": self._model}
51
+
52
+ async def chat(self, messages: list[dict], *, max_tokens: int = 512, **kwargs):
53
+ """Async generator yielding Token objects (streaming)."""
54
+ import json
55
+ import urllib.request
56
+
57
+ key = self._get_key()
58
+ if not key:
59
+ raise RuntimeError(f"{self._api_key_env} not set; HF Inference API unavailable")
60
+
61
+ # Convert chat messages to a single prompt for text-generation endpoint
62
+ prompt = "\n".join(
63
+ f"{'User' if m.get('role') == 'user' else 'Assistant'}: {m.get('content', '')}"
64
+ for m in messages
65
+ )
66
+ prompt += "\nAssistant:"
67
+
68
+ url = f"{self._base_url}/models/{self._model}"
69
+ payload = json.dumps({
70
+ "inputs": prompt,
71
+ "parameters": {"max_new_tokens": max_tokens, "return_full_text": False},
72
+ }).encode()
73
+ req = urllib.request.Request( # nosec B310
74
+ url,
75
+ data=payload,
76
+ headers={
77
+ "Authorization": f"Bearer {key}",
78
+ "Content-Type": "application/json",
79
+ },
80
+ method="POST",
81
+ )
82
+ try:
83
+ with urllib.request.urlopen(req, timeout=30) as resp: # nosec B310
84
+ data = json.loads(resp.read())
85
+ except Exception as exc:
86
+ raise RuntimeError(f"HF Inference API error: {exc}") from exc
87
+
88
+ text = ""
89
+ if isinstance(data, list) and data:
90
+ text = data[0].get("generated_text", "")
91
+ elif isinstance(data, dict):
92
+ text = data.get("generated_text", "")
93
+
94
+ yield Token(text=text, logprob=None, finish_reason="stop")
95
+
96
+ async def complete(self, prompt: str, *, max_tokens: int = 256, **kwargs):
97
+ """Async generator yielding Token objects."""
98
+ async for tok in self.chat([{"role": "user", "content": prompt}], max_tokens=max_tokens):
99
+ yield tok
100
+
101
+ async def close(self) -> None:
102
+ pass
hearthnet/services/llm/backends/lmstudio.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """M04 — LmStudio OpenAI-compatible backend.
2
+
3
+ LM Studio serves a local OpenAI-compatible API on http://localhost:1234/v1.
4
+ Wraps OpenAICompatBackend with LM Studio defaults.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from hearthnet.services.llm.backends.openai_compat import OpenAICompatBackend
10
+
11
+
12
+ class LmStudioBackend(OpenAICompatBackend):
13
+ """LM Studio local inference server.
14
+
15
+ Default endpoint: http://localhost:1234/v1
16
+ LM Studio exposes whichever model is currently loaded; it is discovered
17
+ dynamically via GET /v1/models on first availability check.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ base_url: str = "http://localhost:1234/v1",
23
+ model: str = "local-model",
24
+ api_key_env: str = "",
25
+ ) -> None:
26
+ super().__init__(
27
+ base_url=base_url,
28
+ api_key_env=api_key_env,
29
+ model=model,
30
+ )
31
+
32
+ @property
33
+ def name(self) -> str: # type: ignore[override]
34
+ return "lmstudio"
hearthnet/transport/__init__.py CHANGED
@@ -1,13 +1,19 @@
 
1
  from hearthnet.transport.client import CallError, HttpClient
2
  from hearthnet.transport.server import HttpServer
3
- from hearthnet.transport.streams import SseWriter, encode_sse_frame
4
  from hearthnet.transport.tls import PinnedCerts, generate_self_signed_cert, load_or_generate_cert
5
 
6
  __all__ = [
7
  "CallError",
 
 
8
  "HttpClient",
9
  "HttpServer",
10
  "PinnedCerts",
 
 
 
11
  "SseWriter",
12
  "encode_sse_frame",
13
  "generate_self_signed_cert",
 
1
+ from hearthnet.transport.backpressure import FlowControl, RateCheck, RateLimiter
2
  from hearthnet.transport.client import CallError, HttpClient
3
  from hearthnet.transport.server import HttpServer
4
+ from hearthnet.transport.streams import Frame, SseReader, SseWriter, encode_sse_frame
5
  from hearthnet.transport.tls import PinnedCerts, generate_self_signed_cert, load_or_generate_cert
6
 
7
  __all__ = [
8
  "CallError",
9
+ "FlowControl",
10
+ "Frame",
11
  "HttpClient",
12
  "HttpServer",
13
  "PinnedCerts",
14
+ "RateCheck",
15
+ "RateLimiter",
16
+ "SseReader",
17
  "SseWriter",
18
  "encode_sse_frame",
19
  "generate_self_signed_cert",
hearthnet/transport/backpressure.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """X01 — Backpressure / flow control.
2
+
3
+ Spec: docs/X01-transport.md §3.4
4
+
5
+ FlowControl gates outbound work when a downstream consumer is slow.
6
+ Used by HttpServer SSE streams and WebSocket pub-sub to avoid unbounded queues.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+
13
+
14
+ class FlowControl:
15
+ """Leaky-bucket / semaphore flow control for streaming responses.
16
+
17
+ Usage::
18
+
19
+ fc = FlowControl(capacity=32)
20
+ async with fc.acquire():
21
+ await stream_chunk(data)
22
+
23
+ If the number of in-flight chunks reaches *capacity*, ``acquire()``
24
+ blocks until a slot is freed. This creates natural back-pressure so
25
+ a slow HTTP client cannot cause the server to buffer unbounded data.
26
+ """
27
+
28
+ def __init__(self, capacity: int = 64) -> None:
29
+ if capacity < 1:
30
+ raise ValueError("capacity must be >= 1")
31
+ self._sem = asyncio.Semaphore(capacity)
32
+ self._capacity = capacity
33
+ self._total_acquired: int = 0
34
+ self._total_released: int = 0
35
+
36
+ @property
37
+ def capacity(self) -> int:
38
+ return self._capacity
39
+
40
+ @property
41
+ def in_flight(self) -> int:
42
+ return self._total_acquired - self._total_released
43
+
44
+ def acquire(self) -> "_AcquireContext":
45
+ return _AcquireContext(self)
46
+
47
+ async def _acquire(self) -> None:
48
+ await self._sem.acquire()
49
+ self._total_acquired += 1
50
+
51
+ def _release(self) -> None:
52
+ self._sem.release()
53
+ self._total_released += 1
54
+
55
+ def stats(self) -> dict:
56
+ return {
57
+ "capacity": self._capacity,
58
+ "in_flight": self.in_flight,
59
+ "total_acquired": self._total_acquired,
60
+ "total_released": self._total_released,
61
+ }
62
+
63
+
64
+ class _AcquireContext:
65
+ def __init__(self, fc: FlowControl) -> None:
66
+ self._fc = fc
67
+
68
+ async def __aenter__(self) -> "_AcquireContext":
69
+ await self._fc._acquire()
70
+ return self
71
+
72
+ async def __aexit__(self, *_) -> None:
73
+ self._fc._release()
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # RateCheck / RateLimiter (X01 §3.5)
78
+ # ---------------------------------------------------------------------------
79
+
80
+
81
+ class RateCheck:
82
+ """Simple sliding-window rate check (read-only, no blocking).
83
+
84
+ Use to check whether a call is within limits before proceeding.
85
+ Returns True if allowed, False if over limit.
86
+ """
87
+
88
+ def __init__(self, max_calls: int, window_seconds: float = 1.0) -> None:
89
+ self._max = max_calls
90
+ self._window = window_seconds
91
+ self._calls: list[float] = []
92
+
93
+ def check(self, now: float | None = None) -> bool:
94
+ import time
95
+ t = now if now is not None else time.monotonic()
96
+ cutoff = t - self._window
97
+ self._calls = [c for c in self._calls if c > cutoff]
98
+ if len(self._calls) < self._max:
99
+ self._calls.append(t)
100
+ return True
101
+ return False
102
+
103
+ def reset(self) -> None:
104
+ self._calls.clear()
105
+
106
+
107
+ class RateLimiter:
108
+ """Async rate limiter — blocks until a slot is available.
109
+
110
+ Usage::
111
+
112
+ rl = RateLimiter(max_calls=10, window_seconds=1.0)
113
+ await rl.acquire()
114
+ # ... do work ...
115
+ """
116
+
117
+ def __init__(self, max_calls: int, window_seconds: float = 1.0) -> None:
118
+ self._max = max_calls
119
+ self._window = window_seconds
120
+ self._calls: list[float] = []
121
+ self._lock = asyncio.Lock()
122
+
123
+ async def acquire(self) -> None:
124
+ import time
125
+ while True:
126
+ async with self._lock:
127
+ t = time.monotonic()
128
+ cutoff = t - self._window
129
+ self._calls = [c for c in self._calls if c > cutoff]
130
+ if len(self._calls) < self._max:
131
+ self._calls.append(t)
132
+ return
133
+ await asyncio.sleep(self._window / self._max)
hearthnet/transport/streams.py CHANGED
@@ -28,6 +28,66 @@ async def parse_sse_stream(lines: AsyncIterator[str]) -> AsyncIterator[dict]:
28
  pass
29
 
30
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  class SseWriter:
32
  """Async generator that yields SSE-formatted strings."""
33
 
 
28
  pass
29
 
30
 
31
+ # ---------------------------------------------------------------------------
32
+ # Frame — typed SSE frame (X01 §3.2)
33
+ # ---------------------------------------------------------------------------
34
+
35
+
36
+ class Frame:
37
+ """A single SSE frame with optional event tag and raw data.
38
+
39
+ Spec: X01-transport §3.2 — wire format is ``data: <json>\\n\\n``
40
+ with optional ``event: <tag>\\n`` prefix.
41
+ """
42
+
43
+ __slots__ = ("event", "data", "raw")
44
+
45
+ def __init__(self, data: dict, event: str | None = None) -> None:
46
+ self.data = data
47
+ self.event = event
48
+ self.raw = encode_sse_frame(data, event)
49
+
50
+ def __repr__(self) -> str:
51
+ return f"Frame(event={self.event!r}, data={self.data!r})"
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # SseReader — parse an HTTP SSE response stream (X01 §3.2)
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ class SseReader:
60
+ """Parse a streaming HTTP response into Frame objects.
61
+
62
+ Typical usage with httpx::
63
+
64
+ async with httpx.AsyncClient() as client:
65
+ async with client.stream("POST", url, ...) as resp:
66
+ reader = SseReader(resp.aiter_lines())
67
+ async for frame in reader:
68
+ handle(frame)
69
+ """
70
+
71
+ def __init__(self, lines: AsyncIterator[str]) -> None:
72
+ self._lines = lines
73
+
74
+ async def __aiter__(self) -> AsyncIterator[Frame]:
75
+ event_tag: str | None = None
76
+ async for line in self._lines:
77
+ if line.startswith("event:"):
78
+ event_tag = line[6:].strip()
79
+ elif line.startswith("data:"):
80
+ raw = line[5:].strip()
81
+ try:
82
+ data = json.loads(raw)
83
+ except json.JSONDecodeError:
84
+ data = {"raw": raw}
85
+ yield Frame(data, event_tag)
86
+ event_tag = None
87
+ elif not line.strip():
88
+ event_tag = None # blank separator
89
+
90
+
91
  class SseWriter:
92
  """Async generator that yields SSE-formatted strings."""
93
 
hearthnet/ui/onboarding.py CHANGED
@@ -250,3 +250,8 @@ def _iso_after(seconds: int) -> str:
250
  from datetime import datetime, timedelta
251
 
252
  return (datetime.now(UTC) + timedelta(seconds=seconds)).strftime("%Y-%m-%dT%H:%M:%SZ")
 
 
 
 
 
 
250
  from datetime import datetime, timedelta
251
 
252
  return (datetime.now(UTC) + timedelta(seconds=seconds)).strftime("%Y-%m-%dT%H:%M:%SZ")
253
+
254
+
255
+ # Spec-mandated name (M13 §3.1)
256
+ build_onboarding = build_onboarding_ui
257
+
hearthnet/ui/theme.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """M08 — Gradio theme definitions.
2
+
3
+ Spec: docs/M08-ui.md §7
4
+
5
+ Two themes:
6
+ hearthnet_theme — default purple/dark theme used at all times
7
+ emergency_theme — red-accent override shown in emergency mode
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ try:
13
+ import gradio as gr
14
+
15
+ hearthnet_theme = gr.themes.Soft(
16
+ primary_hue="purple",
17
+ secondary_hue="violet",
18
+ neutral_hue="slate",
19
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
20
+ ).set(
21
+ # CSS variable overrides (spec §7)
22
+ body_background_fill="#1a1a2e",
23
+ body_background_fill_dark="#0f0f1a",
24
+ block_background_fill="#16213e",
25
+ block_border_color="#7c3aed",
26
+ button_primary_background_fill="#7c3aed",
27
+ button_primary_background_fill_hover="#6d28d9",
28
+ button_primary_text_color="#ffffff",
29
+ input_background_fill="#0f3460",
30
+ )
31
+
32
+ emergency_theme = gr.themes.Soft(
33
+ primary_hue="red",
34
+ secondary_hue="orange",
35
+ neutral_hue="zinc",
36
+ font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui", "sans-serif"],
37
+ ).set(
38
+ body_background_fill="#1a0a0a",
39
+ body_background_fill_dark="#0f0505",
40
+ block_background_fill="#2d0000",
41
+ block_border_color="#dc2626",
42
+ button_primary_background_fill="#dc2626",
43
+ button_primary_background_fill_hover="#b91c1c",
44
+ button_primary_text_color="#ffffff",
45
+ input_background_fill="#1f0000",
46
+ )
47
+
48
+ except ImportError:
49
+ # Gradio not installed — provide None sentinels so imports don't fail
50
+ hearthnet_theme = None # type: ignore[assignment]
51
+ emergency_theme = None # type: ignore[assignment]
52
+
53
+ __all__ = ["hearthnet_theme", "emergency_theme"]
hearthnet/ui/topology.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """M08 — Topology visualisation component.
2
+
3
+ Spec: docs/M08-ui.md §3.2
4
+
5
+ Renders the live mesh topology and recent call traces as an HTML widget.
6
+ Updates are pushed via TopologyComponent.push_trace() and push_topology().
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ from collections import deque
14
+ from typing import Any
15
+
16
+ try:
17
+ import gradio as gr
18
+ _HAS_GRADIO = True
19
+ except ImportError:
20
+ _HAS_GRADIO = False
21
+
22
+
23
+ # Max recent call traces to keep in memory
24
+ _MAX_TRACES = 200
25
+ _MAX_TOPOLOGY_HISTORY = 10
26
+
27
+
28
+ class TopologyComponent:
29
+ """Live mesh topology and call-trace viewer.
30
+
31
+ Renders an HTML card showing:
32
+ - Connected peers (node_id, capabilities count, latency)
33
+ - Recent bus call traces (capability, duration_ms, success/error)
34
+ - Local capability count
35
+
36
+ Call push_trace() / push_topology() from bus hooks to keep it live.
37
+ Integrate into Gradio UI via render().
38
+ """
39
+
40
+ def __init__(self, bus: Any = None) -> None:
41
+ self._bus = bus
42
+ self._traces: deque[dict] = deque(maxlen=_MAX_TRACES)
43
+ self._topology: dict = {}
44
+ self._last_updated: float = 0.0
45
+
46
+ def push_trace(self, event: Any) -> None:
47
+ """Accept a CallTraceEvent (or dict) and store it."""
48
+ if hasattr(event, "__dict__"):
49
+ rec = {
50
+ "ts": getattr(event, "ts", time.strftime("%H:%M:%S")),
51
+ "capability": getattr(event, "capability", "?"),
52
+ "duration_ms": getattr(event, "duration_ms", 0),
53
+ "success": getattr(event, "success", True),
54
+ "error": getattr(event, "error", None),
55
+ "peer_node_id": getattr(event, "peer_node_id", "local"),
56
+ }
57
+ elif isinstance(event, dict):
58
+ rec = event
59
+ else:
60
+ return
61
+ self._traces.appendleft(rec)
62
+ self._last_updated = time.monotonic()
63
+
64
+ def push_topology(self, snapshot: Any) -> None:
65
+ """Accept a TopologySnapshot (or dict) and store it."""
66
+ if isinstance(snapshot, dict):
67
+ self._topology = snapshot
68
+ elif hasattr(snapshot, "as_dict"):
69
+ self._topology = snapshot.as_dict()
70
+ elif hasattr(snapshot, "__dict__"):
71
+ self._topology = vars(snapshot)
72
+ self._last_updated = time.monotonic()
73
+
74
+ def render(self) -> Any:
75
+ """Return a Gradio HTML component showing current topology."""
76
+ if not _HAS_GRADIO:
77
+ raise ImportError("gradio is required for TopologyComponent.render()")
78
+
79
+ html = self._build_html()
80
+ return gr.HTML(value=html, label="Mesh Topology")
81
+
82
+ def _build_html(self) -> str:
83
+ peers = self._topology.get("peers", [])
84
+ local_caps = self._topology.get("local_capabilities", 0)
85
+ community = self._topology.get("community_id", "—")
86
+
87
+ # Build peer rows
88
+ peer_rows = ""
89
+ for p in peers[:20]:
90
+ nid = str(p.get("node_id", "?"))[:12]
91
+ caps = p.get("capabilities_count", "?")
92
+ lat = p.get("latency_ms", "?")
93
+ peer_rows += f"<tr><td>{nid}…</td><td>{caps}</td><td>{lat}ms</td></tr>"
94
+ if not peers:
95
+ peer_rows = "<tr><td colspan='3' style='color:#888'>No peers discovered yet</td></tr>"
96
+
97
+ # Build trace rows
98
+ trace_rows = ""
99
+ for t in list(self._traces)[:15]:
100
+ cap = str(t.get("capability", "?"))[:35]
101
+ dur = t.get("duration_ms", "?")
102
+ ok = "✓" if t.get("success", True) else "✗"
103
+ color = "#4ade80" if t.get("success", True) else "#f87171"
104
+ trace_rows += f"<tr><td style='color:{color}'>{ok}</td><td>{cap}</td><td>{dur}ms</td></tr>"
105
+ if not trace_rows:
106
+ trace_rows = "<tr><td colspan='3' style='color:#888'>No calls yet</td></tr>"
107
+
108
+ ts = time.strftime("%H:%M:%S") if self._last_updated else "never"
109
+
110
+ return f"""
111
+ <div style="font-family:monospace;color:#e2e8f0;background:#16213e;padding:12px;border-radius:8px;border:1px solid #7c3aed">
112
+ <div style="display:flex;justify-content:space-between;margin-bottom:8px">
113
+ <span style="font-size:14px;font-weight:600;color:#a78bfa">Mesh Topology</span>
114
+ <span style="font-size:11px;color:#64748b">updated {ts}</span>
115
+ </div>
116
+ <div style="margin-bottom:6px;font-size:12px;color:#94a3b8">
117
+ Community: <b style="color:#c4b5fd">{community}</b> ·
118
+ Local caps: <b style="color:#c4b5fd">{local_caps}</b> ·
119
+ Peers: <b style="color:#c4b5fd">{len(peers)}</b>
120
+ </div>
121
+ <table style="width:100%;font-size:11px;border-collapse:collapse;margin-bottom:10px">
122
+ <thead><tr style="color:#7c3aed"><th>Node</th><th>Caps</th><th>Latency</th></tr></thead>
123
+ <tbody>{peer_rows}</tbody>
124
+ </table>
125
+ <div style="font-size:12px;color:#94a3b8;margin-bottom:4px">Recent calls</div>
126
+ <table style="width:100%;font-size:11px;border-collapse:collapse">
127
+ <thead><tr style="color:#7c3aed"><th></th><th>Capability</th><th>Duration</th></tr></thead>
128
+ <tbody>{trace_rows}</tbody>
129
+ </table>
130
+ </div>
131
+ """
132
+
133
+ def as_dict(self) -> dict:
134
+ return {
135
+ "topology": self._topology,
136
+ "recent_traces": list(self._traces)[:20],
137
+ "last_updated": self._last_updated,
138
+ }