Compare commits

...
Author SHA1 Message Date
Nicolò Boschi 31e9ab8357 feat(embed): expose UI management on HindsightEmbedded
Add start_ui(), stop_ui(), is_ui_running(), and ui_url property
to HindsightEmbedded so the UI can be started programmatically:

  client = HindsightEmbedded(profile="myapp", ...)
  client.start_ui()  # starts daemon + UI
  print(client.ui_url)
2026-03-25 14:20:22 +01:00
Nicolò Boschi 8c2a3f835d feat(embed): add programmatic UI (control plane) management
Add ability to start/stop the web UI from hindsight-embed, with
configurable port (default: daemon_port + 10000) and hostname
(default: 0.0.0.0). Uses npx to run the published control plane
package, or node directly in dev mode.

New CLI commands:
  hindsight-embed ui start [--port PORT] [--hostname HOST]
  hindsight-embed ui stop [--port PORT]
  hindsight-embed ui status [--port PORT]
  hindsight-embed ui logs [-f] [-n N]

New programmatic API:
  daemon_client.start_ui(profile, ui_port, hostname)
  daemon_client.stop_ui(profile, ui_port)
  daemon_client.is_ui_running(profile, ui_port)
  daemon_client.get_ui_url(profile, ui_port)
2026-03-25 11:18:18 +01:00
5 changed files with 523 additions and 4 deletions
+48 -3
View File
@@ -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)
+191
View File
@@ -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 <bank> <content> Store a memory
memory recall <bank> <query> Search memories
@@ -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
@@ -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.
@@ -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