diff --git a/hindsight-all/hindsight/embedded.py b/hindsight-all/hindsight/embedded.py index eacc4502..4622a8b0 100644 --- a/hindsight-all/hindsight/embedded.py +++ b/hindsight-all/hindsight/embedded.py @@ -34,7 +34,6 @@ Using context manager: """ import logging -import os import threading from typing import Optional @@ -140,13 +139,17 @@ class HindsightEmbedded: return if self._closed: - raise RuntimeError("Cannot use HindsightEmbedded after it has been closed") + raise RuntimeError( + "Cannot use HindsightEmbedded after it has been closed" + ) # Use embed manager interface for daemon management logger.info(f"Ensuring daemon is running for profile '{self.profile}'...") success = self._manager.ensure_running(self.config, self.profile) if not success: - raise RuntimeError(f"Failed to start daemon for profile '{self.profile}'") + raise RuntimeError( + f"Failed to start daemon for profile '{self.profile}'" + ) # Get daemon URL and create client daemon_url = self._manager.get_url(self.profile) @@ -375,3 +378,45 @@ class HindsightEmbedded: def is_running(self) -> bool: """Check if the client is initialized.""" return self._started and not self._closed and self._client is not None + + def start_ui(self, ui_port: int | None = None, hostname: str = "0.0.0.0") -> bool: + """Start the control plane web UI. + + The daemon is started automatically if not already running. + + Args: + ui_port: Port for the UI. Defaults to daemon_port + 10000. + hostname: Hostname to bind to. Defaults to 0.0.0.0. + + Returns: + True if UI started successfully. + """ + self._ensure_started() + return self._manager.start_ui(self.profile, ui_port, hostname) + + def stop_ui(self, ui_port: int | None = None) -> bool: + """Stop the control plane web UI. + + Args: + ui_port: Port the UI is running on. Defaults to daemon_port + 10000. + + Returns: + True if stopped successfully. + """ + return self._manager.stop_ui(self.profile, ui_port) + + def is_ui_running(self, ui_port: int | None = None) -> bool: + """Check if the control plane web UI is running. + + Args: + ui_port: Port to check. Defaults to daemon_port + 10000. + + Returns: + True if UI is running and responsive. + """ + return self._manager.is_ui_running(self.profile, ui_port) + + @property + def ui_url(self) -> str: + """Get the UI URL for this profile.""" + return self._manager.get_ui_url(self.profile) diff --git a/hindsight-embed/hindsight_embed/cli.py b/hindsight-embed/hindsight_embed/cli.py index c224a888..6f9c54ea 100644 --- a/hindsight-embed/hindsight_embed/cli.py +++ b/hindsight-embed/hindsight_embed/cli.py @@ -683,6 +683,167 @@ def do_daemon(args, config: dict, logger): return 1 +def do_ui(args, config: dict, logger): + """Handle UI subcommands.""" + from . import daemon_client + from .profile_manager import UI_PORT_OFFSET, ProfileManager + + profile = args.profile + ui_port = getattr(args, "port", None) + hostname = getattr(args, "hostname", "0.0.0.0") + + # Resolve default UI port + pm = ProfileManager() + paths = pm.resolve_profile_paths(profile or "") + default_ui_port = paths.port + UI_PORT_OFFSET + + if args.ui_command == "start": + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + + console = Console() + + effective_port = ui_port or default_ui_port + + if daemon_client.is_ui_running(profile, effective_port): + title = ( + f"[bold yellow]UI Already Running[/bold yellow] [dim]({profile or 'default'} @ :{effective_port})[/dim]" + ) + console.print( + Panel( + Text("UI is already running", style="yellow"), + title=title, + border_style="yellow", + ) + ) + return 0 + + # Ensure daemon is running first + if not daemon_client.is_daemon_running(profile): + console.print("[dim]Daemon not running, starting it first...[/dim]") + if not daemon_client.ensure_daemon_running(config, profile): + console.print( + Panel( + Text("Failed to start daemon (required for UI)", style="red"), + title="[bold red]✗ Error[/bold red]", + border_style="red", + ) + ) + return 1 + + if daemon_client.start_ui(profile, ui_port, hostname): + return 0 + else: + return 1 + + elif args.ui_command == "stop": + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + + console = Console() + effective_port = ui_port or default_ui_port + + if not daemon_client.is_ui_running(profile, effective_port): + title = f"[bold]UI Status[/bold] [dim]({profile or 'default'})[/dim]" + console.print( + Panel( + Text("UI is not running", style="dim"), + title=title, + border_style="dim", + ) + ) + return 0 + + if daemon_client.stop_ui(profile, ui_port): + title = f"[bold green]✓ UI Stopped[/bold green] [dim]({profile or 'default'})[/dim]" + console.print( + Panel( + Text("UI stopped successfully", style="green"), + title=title, + border_style="green", + ) + ) + return 0 + else: + console.print( + Panel( + Text("Failed to stop UI", style="red"), + title="[bold red]✗ Error[/bold red]", + border_style="red", + ) + ) + return 1 + + elif args.ui_command == "status": + from rich.console import Console + from rich.panel import Panel + from rich.text import Text + + console = Console() + effective_port = ui_port or default_ui_port + + if daemon_client.is_ui_running(profile, effective_port): + status_text = Text() + status_text.append("UI is running\n\n", style="green bold") + status_text.append(" URL: ", style="dim") + status_text.append(f"http://127.0.0.1:{effective_port}\n", style="cyan") + status_text.append(" Logs: ", style="dim") + status_text.append(f"{paths.ui_log}", style="") + + title = f"[bold green]✓ UI Running[/bold green] [dim]({profile or 'default'} @ :{effective_port})[/dim]" + console.print( + Panel( + status_text, + title=title, + border_style="green", + padding=(1, 2), + ) + ) + return 0 + else: + title = f"[bold]UI Status[/bold] [dim]({profile or 'default'})[/dim]" + console.print( + Panel( + Text("UI is not running", style="dim"), + title=title, + border_style="dim", + ) + ) + return 1 + + elif args.ui_command == "logs": + ui_log_path = paths.ui_log + if not ui_log_path.exists(): + print("No UI logs found", file=sys.stderr) + print(f" Expected at: {ui_log_path}") + return 1 + + if args.follow: + import subprocess + + try: + subprocess.run(["tail", "-f", str(ui_log_path)]) + except KeyboardInterrupt: + pass + return 0 + else: + try: + with open(ui_log_path) as f: + lines = f.readlines() + for line in lines[-args.lines :]: + print(line, end="") + return 0 + except Exception as e: + print(f"Error reading logs: {e}", file=sys.stderr) + return 1 + + else: + print("Usage: hindsight-embed ui {start|stop|status|logs}", file=sys.stderr) + return 1 + + def _do_configure_profile_with_env(profile_name: str, port: int | None, env_vars: list[str]) -> int: """Configure a named profile with environment variables (non-interactive). @@ -1247,6 +1408,30 @@ def main(): exit_code = do_daemon(args, config, logger) sys.exit(exit_code) + # Handle UI subcommands + if command == "ui": + parser = argparse.ArgumentParser(prog="hindsight-embed ui") + subparsers = parser.add_subparsers(dest="ui_command") + start_parser = subparsers.add_parser("start", help="Start the UI") + start_parser.add_argument("--port", type=int, help="Port for the UI (default: daemon_port + 10000)") + start_parser.add_argument( + "--hostname", "-H", default="0.0.0.0", help="Hostname to bind to (default: 0.0.0.0)" + ) + stop_parser = subparsers.add_parser("stop", help="Stop the UI") + stop_parser.add_argument("--port", type=int, help="Port the UI is running on") + status_parser = subparsers.add_parser("status", help="Check UI status") + status_parser.add_argument("--port", type=int, help="Port to check") + logs_parser = subparsers.add_parser("logs", help="View UI logs") + logs_parser.add_argument("--follow", "-f", action="store_true") + logs_parser.add_argument("--lines", "-n", type=int, default=50) + + args = parser.parse_args(remaining_args[1:]) + args.profile = global_profile + logger = setup_logging(False) + config = get_config() + exit_code = do_ui(args, config, logger) + sys.exit(exit_code) + # Handle --help / -h if command in ("--help", "-h"): print_help() @@ -1305,6 +1490,12 @@ Daemon management: daemon status Check daemon status daemon logs [-f] [-n] View daemon logs +UI (control plane): + ui start [--port PORT] [--hostname HOST] Start the web UI (default port: daemon_port + 10000) + ui stop [--port PORT] Stop the web UI + ui status [--port PORT] Check UI status + ui logs [-f] [-n] View UI logs + CLI commands (forwarded to hindsight-cli): memory retain Store a memory memory recall Search memories diff --git a/hindsight-embed/hindsight_embed/daemon_client.py b/hindsight-embed/hindsight_embed/daemon_client.py index 23161e09..87912ebc 100644 --- a/hindsight-embed/hindsight_embed/daemon_client.py +++ b/hindsight-embed/hindsight_embed/daemon_client.py @@ -104,6 +104,67 @@ def is_daemon_running(profile: str | None = None) -> bool: return _manager.is_running(profile) +def start_ui(profile: str | None = None, ui_port: int | None = None, hostname: str = "0.0.0.0") -> bool: + """Start the control plane UI. + + Args: + profile: Profile name (None = resolve from priority). + ui_port: Port for the UI. Defaults to daemon_port + 10000. + hostname: Hostname to bind to. Defaults to 0.0.0.0. + + Returns: + True if UI started successfully. + """ + if profile is None: + profile = resolve_active_profile() + return _manager.start_ui(profile, ui_port, hostname) + + +def stop_ui(profile: str | None = None, ui_port: int | None = None) -> bool: + """Stop the control plane UI. + + Args: + profile: Profile name (None = resolve from priority). + ui_port: Port the UI is running on. Defaults to daemon_port + 10000. + + Returns: + True if UI stopped successfully. + """ + if profile is None: + profile = resolve_active_profile() + return _manager.stop_ui(profile, ui_port) + + +def is_ui_running(profile: str | None = None, ui_port: int | None = None) -> bool: + """Check if the UI is running. + + Args: + profile: Profile name (None = resolve from priority). + ui_port: Port to check. Defaults to daemon_port + 10000. + + Returns: + True if UI is running and responsive. + """ + if profile is None: + profile = resolve_active_profile() + return _manager.is_ui_running(profile, ui_port) + + +def get_ui_url(profile: str | None = None, ui_port: int | None = None) -> str: + """Get UI URL for a profile. + + Args: + profile: Profile name (None = resolve from priority). + ui_port: Port for the UI. Defaults to daemon_port + 10000. + + Returns: + URL for the UI. + """ + if profile is None: + profile = resolve_active_profile() + return _manager.get_ui_url(profile, ui_port) + + def find_cli_binary() -> Path | None: """Find the hindsight CLI binary in known locations or PATH.""" import shutil diff --git a/hindsight-embed/hindsight_embed/daemon_embed_manager.py b/hindsight-embed/hindsight_embed/daemon_embed_manager.py index 6b634f37..fc572e9a 100644 --- a/hindsight-embed/hindsight_embed/daemon_embed_manager.py +++ b/hindsight-embed/hindsight_embed/daemon_embed_manager.py @@ -20,7 +20,7 @@ from rich.panel import Panel from rich.text import Text from .embed_manager import EmbedManager -from .profile_manager import ProfileManager, resolve_active_profile +from .profile_manager import UI_PORT_OFFSET, ProfileManager, resolve_active_profile logger = logging.getLogger(__name__) console = Console(stderr=True) @@ -322,6 +322,219 @@ class DaemonEmbedManager(EmbedManager): except Exception as e: logger.debug(f"Failed to register profile '{profile}' in metadata: {e}") + def _find_ui_command(self) -> list[str]: + """Find the command to run the control plane UI.""" + # Check if we're in development mode (monorepo) + dev_cp_path = Path(__file__).parent.parent.parent / "hindsight-control-plane" + cli_js = dev_cp_path / "bin" / "cli.js" + if cli_js.exists(): + return ["node", str(cli_js)] + + # Use npx to run the published control plane package + from . import __version__ + + cp_version = os.getenv("HINDSIGHT_EMBED_CP_VERSION", __version__) + return ["npx", f"@vectorize-io/hindsight-control-plane@{cp_version}"] + + def get_ui_url(self, profile: str, ui_port: int | None = None, hostname: str | None = None) -> str: + """Get the URL for the UI serving this profile.""" + if ui_port is None: + paths = self._profile_manager.resolve_profile_paths(profile) + ui_port = paths.port + UI_PORT_OFFSET + host = hostname or "0.0.0.0" + return f"http://{host}:{ui_port}" + + def is_ui_running(self, profile: str, ui_port: int | None = None) -> bool: + """Check if the UI is running and responsive.""" + # Always health-check on 127.0.0.1 regardless of bind hostname + ui_url = self.get_ui_url(profile, ui_port, hostname="127.0.0.1") + try: + with httpx.Client(timeout=2) as client: + response = client.get(f"{ui_url}/api/health") + return response.status_code == 200 + except Exception: + return False + + def start_ui(self, profile: str, ui_port: int | None = None, hostname: str = "0.0.0.0") -> bool: + """Start the control plane UI in background. + + Args: + profile: Profile name. + ui_port: Port for the UI. Defaults to daemon_port + 10000. + hostname: Hostname to bind to. Defaults to 0.0.0.0. + + Returns: + True if UI started successfully. + """ + paths = self._profile_manager.resolve_profile_paths(profile) + if ui_port is None: + ui_port = paths.port + UI_PORT_OFFSET + + if self.is_ui_running(profile, ui_port): + logger.debug(f"UI already running for profile '{profile}' on port {ui_port}") + return True + + profile_label = f"profile '{profile}'" if profile else "default profile" + api_url = self.get_url(profile) + ui_log = paths.ui_log + + # Build environment + env = os.environ.copy() + env["PORT"] = str(ui_port) + env["HOSTNAME"] = hostname + env["HINDSIGHT_CP_DATAPLANE_API_URL"] = api_url + + # Create log directory + ui_log.parent.mkdir(parents=True, exist_ok=True) + + # Build command + cmd = self._find_ui_command() + [ + "--port", + str(ui_port), + "--hostname", + hostname, + "--api-url", + api_url, + ] + + try: + log_file = open(ui_log, "w") + subprocess.Popen( + cmd, + env=env, + start_new_session=True, + stdout=log_file, + stderr=log_file, + ) + + # Wait for UI to be ready + start_time = time.time() + title = f"[bold cyan]Starting UI[/bold cyan] [dim]({profile or 'default'} @ :{ui_port})[/dim]" + log_lines = [f"Starting UI for {profile_label}...", ""] + + with Live(console=console, auto_refresh=False) as live: + content = Text("\n".join(log_lines), style="dim") + panel = Panel(content, title=title, border_style="cyan", padding=(1, 2)) + live.update(panel) + live.refresh() + + while time.time() - start_time < 30: + if self.is_ui_running(profile, ui_port): + log_lines.append(f"✓ UI started at http://127.0.0.1:{ui_port}") + log_lines.append(f"Logs: {ui_log}") + content = Text("\n".join(log_lines), style="dim") + success_title = ( + f"[bold green]✓ UI Started[/bold green] [dim]({profile or 'default'} @ :{ui_port})[/dim]" + ) + panel = Panel(content, title=success_title, border_style="green", padding=(1, 2)) + live.update(panel) + live.refresh() + console.print() + return True + + elapsed = int(time.time() - start_time) + status_msg = f"⏳ Waiting for UI... ({elapsed}s elapsed)" + if log_lines and log_lines[-1].startswith("⏳"): + log_lines[-1] = status_msg + else: + log_lines.append(status_msg) + + content = Text("\n".join(log_lines), style="dim") + panel = Panel(content, title=title, border_style="cyan", padding=(1, 2)) + live.update(panel) + live.refresh() + time.sleep(0.5) + + # Timeout + console.print( + Panel( + Text(f"UI failed to start (timeout)\n\nSee full log: {ui_log}", style="dim"), + title=f"[bold red]✗ UI Failed (Timeout)[/bold red] [dim](:{ui_port})[/dim]", + border_style="red", + padding=(1, 2), + ) + ) + console.print() + return False + + except FileNotFoundError: + error_msg = ( + f"Command not found: {cmd[0]}\nFull command: {' '.join(cmd)}\n\nInstall Node.js and npx to run the UI." + ) + console.print( + Panel( + Text(error_msg, style="red"), + title="[bold red]✗ Command Not Found[/bold red]", + border_style="red", + padding=(1, 2), + ) + ) + console.print() + return False + except Exception as e: + error_msg = f"Failed to start UI: {e}\n\nCommand: {' '.join(cmd)}\nLog file: {ui_log}" + console.print( + Panel( + Text(error_msg, style="red"), + title="[bold red]✗ UI Startup Error[/bold red]", + border_style="red", + padding=(1, 2), + ) + ) + console.print() + return False + + def stop_ui(self, profile: str, ui_port: int | None = None) -> bool: + """Stop the UI for this profile. + + Args: + profile: Profile name. + ui_port: Port the UI is running on. Defaults to daemon_port + 10000. + + Returns: + True if stopped successfully. + """ + paths = self._profile_manager.resolve_profile_paths(profile) + if ui_port is None: + ui_port = paths.port + UI_PORT_OFFSET + + if not self.is_ui_running(profile, ui_port): + logger.debug(f"UI not running for profile '{profile}'") + return True + + # Find PID by port + try: + result = subprocess.run( + ["lsof", "-ti", f":{ui_port}", "-sTCP:LISTEN"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0 and result.stdout.strip(): + pid = int(result.stdout.strip().split()[0]) + logger.debug(f"Found UI PID {pid} on port {ui_port}") + os.kill(pid, 15) + + # Wait for process to exit + for _ in range(50): + time.sleep(0.1) + try: + os.kill(pid, 0) + except OSError: + break + else: + logger.warning(f"Could not find PID for UI port {ui_port}") + except (subprocess.TimeoutExpired, ValueError, OSError, FileNotFoundError) as e: + logger.warning(f"Could not find/kill UI by port: {e}") + + # Wait for health check to fail + for _ in range(30): + if not self.is_ui_running(profile, ui_port): + return True + time.sleep(0.1) + + return not self.is_ui_running(profile, ui_port) + def ensure_running(self, config: dict, profile: str) -> bool: """ Ensure daemon is running, starting it if needed. diff --git a/hindsight-embed/hindsight_embed/profile_manager.py b/hindsight-embed/hindsight_embed/profile_manager.py index 25ff76c1..d208b01b 100644 --- a/hindsight-embed/hindsight_embed/profile_manager.py +++ b/hindsight-embed/hindsight_embed/profile_manager.py @@ -28,6 +28,10 @@ PROFILE_PORT_BASE = 8889 PROFILE_PORT_RANGE = 1000 # 8889-9888 +# UI port offset from daemon port (e.g., daemon 8888 -> UI 18888) +UI_PORT_OFFSET = 10000 + + @dataclass class ProfilePaths: """Paths and port for a profile.""" @@ -36,6 +40,11 @@ class ProfilePaths: lock: Path log: Path port: int + ui_log: Path = None # type: ignore[assignment] + + def __post_init__(self): + if self.ui_log is None: + self.ui_log = self.log.parent / self.log.name.replace(".log", ".ui.log") @dataclass