diff --git a/modules/laptop_dashboard.py b/modules/laptop_dashboard.py index d5d0384..fc7e0b3 100644 --- a/modules/laptop_dashboard.py +++ b/modules/laptop_dashboard.py @@ -1,8 +1,18 @@ from datetime import datetime +import json import math from pathlib import Path - -from PyQt5.QtCore import Qt, QRectF, QTimer +import subprocess +import urllib.error +import urllib.request + +from PyQt5.QtCore import ( + Qt, + QRectF, + QThread, + QTimer, + pyqtSignal, +) from PyQt5.QtGui import ( QBrush, QColor, @@ -785,6 +795,743 @@ class ShireCenterCore(QWidget): ) + +def _brain_node_base_url(): + """Resolve the local Brain Node API on its Tailnet-only listener.""" + try: + result = subprocess.run( + ["tailscale", "ip", "-4"], + check=False, + capture_output=True, + text=True, + timeout=4, + ) + except Exception: + return "" + + for line in result.stdout.splitlines(): + address = line.strip() + + if address: + return f"http://{address}:8765" + + return "" + + +def _brain_node_json(url, timeout): + request = urllib.request.Request(url, method="GET") + + with urllib.request.urlopen( + request, + timeout=timeout, + ) as response: + return json.loads( + response.read().decode("utf-8") + ) + + +def fetch_brain_node_snapshot(): + """Fetch truthful worker state without assigning it authority.""" + base_url = _brain_node_base_url() + + if not base_url: + return { + "reachable": False, + "error": "Tailnet address unavailable", + } + + try: + heartbeat = _brain_node_json( + base_url + "/worker/heartbeat", + 4, + ) + capabilities = _brain_node_json( + base_url + "/worker/capabilities", + 12, + ) + + return { + "reachable": True, + "base_url": base_url, + "heartbeat": heartbeat, + "capabilities": capabilities, + "error": None, + } + + except ( + OSError, + ValueError, + urllib.error.HTTPError, + urllib.error.URLError, + ) as exc: + return { + "reachable": False, + "base_url": base_url, + "error": f"{type(exc).__name__}: {exc}", + } + + +class BrainNodeProbe(QThread): + """Non-blocking heartbeat probe for the laptop dashboard.""" + + completed = pyqtSignal(dict) + + def run(self): + self.completed.emit(fetch_brain_node_snapshot()) + + +class BrainNodeVisual(QWidget): + """Animated Brain Node identity driven by real worker heartbeat.""" + + def __init__(self): + super().__init__() + + self.phase = 0.0 + self.node_state = "CONNECTING" + self.state_detail = "WAITING FOR WORKER HEARTBEAT" + self.capability_detail = "CAPABILITIES UNKNOWN" + self.model_detail = "DEEP MODEL UNKNOWN" + self.authority_detail = "COMMAND CORE AUTHORITY CHECK PENDING" + self.sequence = 0 + self.probe = None + + self.setMinimumHeight(255) + self.setSizePolicy( + QSizePolicy.Expanding, + QSizePolicy.Expanding, + ) + + self.animation_timer = QTimer(self) + self.animation_timer.setTimerType(Qt.PreciseTimer) + self.animation_timer.timeout.connect( + self._animate + ) + self.animation_timer.start(80) + + self.poll_timer = QTimer(self) + self.poll_timer.timeout.connect( + self.refresh_heartbeat + ) + self.poll_timer.start(3000) + + QTimer.singleShot( + 50, + self.refresh_heartbeat, + ) + + def _animate(self): + speed = 0.018 + + if self.node_state == "ONLINE": + speed = 0.075 + elif self.node_state == "DEGRADED": + speed = 0.038 + elif self.node_state in {"OFFLINE", "AUTHORITY FAULT"}: + speed = 0.006 + + self.phase = ( + self.phase + speed + ) % (math.pi * 2000.0) + + self.update() + + def refresh_heartbeat(self): + if self.probe is not None and self.probe.isRunning(): + return + + self.probe = BrainNodeProbe(self) + self.probe.completed.connect( + self.apply_snapshot + ) + self.probe.finished.connect( + self._probe_finished + ) + self.probe.start() + + def _probe_finished(self): + if self.probe is not None: + self.probe.deleteLater() + + self.probe = None + + def apply_snapshot(self, snapshot): + if not snapshot.get("reachable"): + self.node_state = "OFFLINE" + self.state_detail = "WORKER HEARTBEAT UNAVAILABLE" + self.capability_detail = "LOCAL DISPLAY ONLY" + self.model_detail = "NO REMOTE COMPUTE CONNECTION" + self.authority_detail = "NO AUTHORITY TRANSFER" + self.update() + return + + heartbeat = snapshot.get("heartbeat", {}) + capability_payload = snapshot.get( + "capabilities", + {}, + ) + + reported_state = str( + heartbeat.get("state", "degraded") + ).strip().upper() + + if reported_state not in { + "ONLINE", + "DEGRADED", + }: + reported_state = "DEGRADED" + + ownership = capability_payload.get( + "ownership", + heartbeat.get("ownership", {}), + ) + + prohibited_authority = ( + "owns_projects", + "owns_approvals", + "owns_memory", + "owns_shirevault", + "makes_safety_decisions", + ) + + authority_safe = all( + ownership.get(name) is False + for name in prohibited_authority + ) + + if not authority_safe: + self.node_state = "AUTHORITY FAULT" + self.authority_detail = ( + "WORKER AUTHORITY BOUNDARY VIOLATION" + ) + else: + self.node_state = reported_state + self.authority_detail = ( + "MINI OWNS PROJECTS • APPROVALS • SAFETY" + ) + + self.sequence = int( + heartbeat.get("sequence", 0) + ) + + summary = capability_payload.get( + "summary", + heartbeat.get("capability_summary", {}), + ) + + available = int(summary.get("available", 0)) + registered = int(summary.get("registered", 0)) + + self.capability_detail = ( + f"{available}/{registered} CAPABILITIES AVAILABLE" + ) + + deep_model = "DEEP MODEL UNKNOWN" + + for capability in capability_payload.get( + "capabilities", + [], + ): + if capability.get("id") == "deep_reasoning": + dependencies = capability.get( + "dependencies", + [], + ) + + if dependencies: + deep_model = str(dependencies[0]) + + break + + self.model_detail = deep_model.upper() + + uptime = heartbeat.get("uptime_seconds", 0) + + self.state_detail = ( + f"HEARTBEAT {self.sequence} • " + f"UPTIME {int(float(uptime))}S" + ) + + self.update() + + def showEvent(self, event): + super().showEvent(event) + + if not self.animation_timer.isActive(): + self.animation_timer.start(80) + + if not self.poll_timer.isActive(): + self.poll_timer.start(3000) + + self.refresh_heartbeat() + + def hideEvent(self, event): + if self.animation_timer.isActive(): + self.animation_timer.stop() + + if self.poll_timer.isActive(): + self.poll_timer.stop() + + super().hideEvent(event) + + def _state_color(self): + if self.node_state == "ONLINE": + return QColor(GREEN) + + if self.node_state == "DEGRADED": + return QColor("#ffbd4a") + + if self.node_state == "AUTHORITY FAULT": + return QColor("#ff4265") + + if self.node_state == "CONNECTING": + return QColor(CYAN) + + return QColor("#7f728f") + + @staticmethod + def _brain_paths(cx, cy, scale): + left = QPainterPath() + left.moveTo(cx - 2 * scale, cy - 68 * scale) + left.cubicTo( + cx - 34 * scale, + cy - 84 * scale, + cx - 65 * scale, + cy - 66 * scale, + cx - 62 * scale, + cy - 38 * scale, + ) + left.cubicTo( + cx - 82 * scale, + cy - 25 * scale, + cx - 76 * scale, + cy + 6 * scale, + cx - 58 * scale, + cy + 15 * scale, + ) + left.cubicTo( + cx - 70 * scale, + cy + 43 * scale, + cx - 48 * scale, + cy + 69 * scale, + cx - 25 * scale, + cy + 60 * scale, + ) + left.cubicTo( + cx - 13 * scale, + cy + 73 * scale, + cx - 2 * scale, + cy + 55 * scale, + cx - 2 * scale, + cy + 31 * scale, + ) + left.closeSubpath() + + right = QPainterPath() + right.moveTo(cx + 2 * scale, cy - 68 * scale) + right.cubicTo( + cx + 34 * scale, + cy - 84 * scale, + cx + 65 * scale, + cy - 66 * scale, + cx + 62 * scale, + cy - 38 * scale, + ) + right.cubicTo( + cx + 82 * scale, + cy - 25 * scale, + cx + 76 * scale, + cy + 6 * scale, + cx + 58 * scale, + cy + 15 * scale, + ) + right.cubicTo( + cx + 70 * scale, + cy + 43 * scale, + cx + 48 * scale, + cy + 69 * scale, + cx + 25 * scale, + cy + 60 * scale, + ) + right.cubicTo( + cx + 13 * scale, + cy + 73 * scale, + cx + 2 * scale, + cy + 55 * scale, + cx + 2 * scale, + cy + 31 * scale, + ) + right.closeSubpath() + + return left, right + + def _draw_electricity( + self, + painter, + cx, + cy, + radius_x, + radius_y, + color, + ): + if self.node_state not in { + "ONLINE", + "DEGRADED", + }: + return + + arc_count = ( + 8 if self.node_state == "ONLINE" else 4 + ) + + for arc_index in range(arc_count): + start = ( + self.phase + + arc_index + * (math.pi * 2.0 / arc_count) + ) + + path = QPainterPath() + + for point_index in range(9): + angle = start + point_index * 0.105 + jitter = ( + math.sin( + self.phase * 5.0 + + arc_index * 2.3 + + point_index * 3.1 + ) + * 6.0 + ) + + x = ( + cx + + math.cos(angle) + * (radius_x + jitter) + ) + y = ( + cy + + math.sin(angle) + * (radius_y + jitter * 0.45) + ) + + if point_index == 0: + path.moveTo(x, y) + else: + path.lineTo(x, y) + + pulse = ( + math.sin( + self.phase * 7.0 + + arc_index + ) + + 1.0 + ) / 2.0 + + electric = QColor(color) + electric.setAlpha( + int(95 + pulse * 155) + ) + + painter.setPen( + QPen( + electric, + 1.2 + pulse * 1.8, + Qt.SolidLine, + Qt.RoundCap, + Qt.RoundJoin, + ) + ) + painter.setBrush(Qt.NoBrush) + painter.drawPath(path) + + def paintEvent(self, event): + del event + + painter = QPainter(self) + painter.setRenderHint( + QPainter.Antialiasing, + True, + ) + + w = float(self.width()) + h = float(self.height()) + cx = w / 2.0 + cy = h * 0.40 + scale = max( + 0.65, + min(w / 250.0, h / 320.0), + ) + + state_color = self._state_color() + + pulse = ( + math.sin(self.phase * 2.4) + 1.0 + ) / 2.0 + + aura = QRadialGradient( + cx, + cy, + 118.0 * scale, + ) + + center_color = QColor(state_color) + center_color.setAlpha( + int(45 + pulse * 55) + ) + + aura.setColorAt(0.0, center_color) + aura.setColorAt( + 0.55, + QColor(60, 15, 105, 40), + ) + aura.setColorAt( + 1.0, + QColor(0, 0, 0, 0), + ) + + painter.setPen(Qt.NoPen) + painter.setBrush(QBrush(aura)) + painter.drawEllipse( + QRectF( + cx - 118 * scale, + cy - 105 * scale, + 236 * scale, + 210 * scale, + ) + ) + + self._draw_electricity( + painter, + cx, + cy, + 93 * scale, + 75 * scale, + state_color, + ) + + left, right = self._brain_paths( + cx, + cy, + scale, + ) + + brain_fill = QRadialGradient( + cx, + cy - 12 * scale, + 92 * scale, + ) + brain_fill.setColorAt( + 0.0, + QColor(75, 30, 125, 245), + ) + brain_fill.setColorAt( + 0.52, + QColor(36, 12, 73, 245), + ) + brain_fill.setColorAt( + 1.0, + QColor(10, 5, 28, 250), + ) + + outline = QColor(state_color) + outline.setAlpha( + int(185 + pulse * 65) + ) + + painter.setPen( + QPen(outline, 2.3) + ) + painter.setBrush(QBrush(brain_fill)) + painter.drawPath(left) + painter.drawPath(right) + + groove_pen = QColor(PURPLE_LIGHT) + groove_pen.setAlpha(150) + + painter.setPen( + QPen( + groove_pen, + 1.35, + Qt.SolidLine, + Qt.RoundCap, + ) + ) + painter.setBrush(Qt.NoBrush) + + grooves = ( + (-47, -44, -21, -29, -42, -9), + (-58, -15, -28, -4, -45, 18), + (-47, 23, -22, 19, -32, 47), + (-24, -58, -11, -37, -24, -14), + (47, -44, 21, -29, 42, -9), + (58, -15, 28, -4, 45, 18), + (47, 23, 22, 19, 32, 47), + (24, -58, 11, -37, 24, -14), + ) + + for x1, y1, cx1, cy1, x2, y2 in grooves: + path = QPainterPath() + path.moveTo( + cx + x1 * scale, + cy + y1 * scale, + ) + path.quadTo( + cx + cx1 * scale, + cy + cy1 * scale, + cx + x2 * scale, + cy + y2 * scale, + ) + painter.drawPath(path) + + neural_points = ( + (-42, -32), + (-20, -46), + (-35, 2), + (-16, 25), + (-31, 43), + (42, -32), + (20, -46), + (35, 2), + (16, 25), + (31, 43), + (0, -18), + (0, 19), + ) + + neural_links = ( + (0, 1), + (0, 2), + (1, 10), + (2, 3), + (3, 4), + (3, 11), + (5, 6), + (5, 7), + (6, 10), + (7, 8), + (8, 9), + (8, 11), + (10, 11), + ) + + for link_index, (first, second) in enumerate( + neural_links + ): + x1, y1 = neural_points[first] + x2, y2 = neural_points[second] + + glow = ( + math.sin( + self.phase * 4.5 + + link_index * 0.75 + ) + + 1.0 + ) / 2.0 + + link_color = QColor(state_color) + link_color.setAlpha( + int(55 + glow * 145) + ) + + painter.setPen( + QPen(link_color, 1.1) + ) + painter.drawLine( + int(cx + x1 * scale), + int(cy + y1 * scale), + int(cx + x2 * scale), + int(cy + y2 * scale), + ) + + for point_index, (x, y) in enumerate( + neural_points + ): + node_pulse = ( + math.sin( + self.phase * 5.5 + + point_index * 0.9 + ) + + 1.0 + ) / 2.0 + + node_color = QColor(state_color) + node_color.setAlpha( + int(145 + node_pulse * 110) + ) + + radius = ( + 2.0 + node_pulse * 1.8 + ) * scale + + painter.setPen(Qt.NoPen) + painter.setBrush(node_color) + painter.drawEllipse( + QRectF( + cx + x * scale - radius, + cy + y * scale - radius, + radius * 2, + radius * 2, + ) + ) + + label_top = h - 82 + + painter.setPen(state_color) + painter.setFont( + QFont( + "DejaVu Sans Mono", + 13, + QFont.Bold, + ) + ) + painter.drawText( + QRectF(0, label_top, w, 20), + Qt.AlignCenter, + f"BRAIN NODE 01 • {self.node_state}", + ) + + painter.setPen(QColor(PURPLE_LIGHT)) + painter.setFont( + QFont( + "DejaVu Sans Mono", + 8, + QFont.Bold, + ) + ) + painter.drawText( + QRectF(0, label_top + 21, w, 15), + Qt.AlignCenter, + self.state_detail, + ) + + painter.setPen(QColor(CYAN)) + painter.drawText( + QRectF(0, label_top + 37, w, 15), + Qt.AlignCenter, + self.capability_detail, + ) + + painter.setPen(QColor(MUTED)) + painter.drawText( + QRectF(0, label_top + 53, w, 15), + Qt.AlignCenter, + self.model_detail, + ) + + authority_color = ( + QColor(GREEN) + if self.node_state != "AUTHORITY FAULT" + else QColor("#ff4265") + ) + + painter.setPen(authority_color) + painter.drawText( + QRectF(0, label_top + 68, w, 14), + Qt.AlignCenter, + self.authority_detail, + ) + + class VoiceOrb(QWidget): """Visual mirror of the approved manual voice state.""" @@ -1035,6 +1782,20 @@ class LaptopDashboard(QWidget): return card + def _build_brain_node_card(self): + card = PresenceCard( + "DISTRIBUTED BRAIN NODE", + CYAN, + ) + + self.brain_node_visual = BrainNodeVisual() + card.column.addWidget( + self.brain_node_visual, + 1, + ) + + return card + def _build_thought_card(self): card = PresenceCard("CURRENT THOUGHT", PURPLE_LIGHT) @@ -1167,7 +1928,7 @@ class LaptopDashboard(QWidget): brand_column = QVBoxLayout() brand_column.setSpacing(0) - brand = QLabel("SHIRE AIOS") + brand = QLabel("SHIRE BRAIN NODE 01") brand.setStyleSheet( f"color:{PURPLE}; " "font-size:27px; " @@ -1175,7 +1936,7 @@ class LaptopDashboard(QWidget): "letter-spacing:2px;" ) - core_state = QLabel("CORE ONLINE") + core_state = QLabel("DISTRIBUTED WORKER • SHIRE MINI COMMAND CORE") core_state.setStyleSheet( f"color:{GREEN}; " "font-size:10px; " @@ -1217,7 +1978,7 @@ class LaptopDashboard(QWidget): right_column = QVBoxLayout() right_column.setSpacing(12) - right_column.addWidget(self._build_thought_card(), 3) + right_column.addWidget(self._build_brain_node_card(), 3) right_column.addWidget(self._build_next_card(), 2) stage.addLayout(left_column, 5)