Improve planoai up/down CLI progress output (#858)

This commit is contained in:
Musa 2026-03-31 17:26:32 -04:00 committed by GitHub
parent 82f34f82f2
commit 36fa42b364
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 263 additions and 158 deletions

View file

@ -3,6 +3,8 @@ import os
import multiprocessing import multiprocessing
import subprocess import subprocess
import sys import sys
import contextlib
import logging
import rich_click as click import rich_click as click
from planoai import targets from planoai import targets
@ -33,6 +35,7 @@ from planoai.consts import (
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT, DEFAULT_OTEL_TRACING_GRPC_ENDPOINT,
DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT, DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT,
NATIVE_PID_FILE, NATIVE_PID_FILE,
PLANO_RUN_DIR,
PLANO_DOCKER_IMAGE, PLANO_DOCKER_IMAGE,
PLANO_DOCKER_NAME, PLANO_DOCKER_NAME,
) )
@ -101,6 +104,20 @@ def _print_cli_header(console) -> None:
) )
@contextlib.contextmanager
def _temporary_cli_log_level(level: str | None):
if level is None:
yield
return
current_level = logging.getLevelName(logging.getLogger().level).lower()
set_log_level(level)
try:
yield
finally:
set_log_level(current_level)
def _print_missing_keys(console, missing_keys: list[str]) -> None: def _print_missing_keys(console, missing_keys: list[str]) -> None:
console.print(f"\n[red]✗[/red] [red]Missing API keys![/red]\n") console.print(f"\n[red]✗[/red] [red]Missing API keys![/red]\n")
for key in missing_keys: for key in missing_keys:
@ -293,163 +310,204 @@ def build(docker):
help="Run Plano inside Docker instead of natively.", help="Run Plano inside Docker instead of natively.",
is_flag=True, is_flag=True,
) )
def up(file, path, foreground, with_tracing, tracing_port, docker): @click.option(
"--verbose",
"-v",
default=False,
help="Show detailed startup logs with timestamps.",
is_flag=True,
)
def up(file, path, foreground, with_tracing, tracing_port, docker, verbose):
"""Starts Plano.""" """Starts Plano."""
from rich.status import Status from rich.status import Status
console = _console() console = _console()
_print_cli_header(console) _print_cli_header(console)
# Use the utility function to find config file with _temporary_cli_log_level("warning" if not verbose else None):
plano_config_file = find_config_file(path, file) # Use the utility function to find config file
plano_config_file = find_config_file(path, file)
# Check if the file exists # Check if the file exists
if not os.path.exists(plano_config_file): if not os.path.exists(plano_config_file):
console.print( console.print(
f"[red]✗[/red] Config file not found: [dim]{plano_config_file}[/dim]" f"[red]✗[/red] Config file not found: [dim]{plano_config_file}[/dim]"
) )
sys.exit(1)
if not docker:
from planoai.native_runner import native_validate_config
with Status(
"[dim]Validating configuration[/dim]",
spinner="dots",
spinner_style="dim",
):
try:
native_validate_config(plano_config_file)
except SystemExit:
console.print(f"[red]✗[/red] Validation failed")
sys.exit(1)
except Exception as e:
console.print(f"[red]✗[/red] Validation failed")
console.print(f" [dim]{str(e).strip()}[/dim]")
sys.exit(1)
else:
with Status(
"[dim]Validating configuration (Docker)[/dim]",
spinner="dots",
spinner_style="dim",
):
(
validation_return_code,
_,
validation_stderr,
) = docker_validate_plano_schema(plano_config_file)
if validation_return_code != 0:
console.print(f"[red]✗[/red] Validation failed")
if validation_stderr:
console.print(f" [dim]{validation_stderr.strip()}[/dim]")
sys.exit(1) sys.exit(1)
log.info("Configuration valid") if not docker:
from planoai.native_runner import native_validate_config
# Set up environment with Status(
default_otel = ( "[dim]Validating configuration[/dim]",
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT spinner="dots",
if docker spinner_style="dim",
else DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT ):
) try:
env_stage = { native_validate_config(plano_config_file)
"OTEL_TRACING_GRPC_ENDPOINT": default_otel, except SystemExit:
} console.print(f"[red]✗[/red] Validation failed")
env = os.environ.copy() sys.exit(1)
env.pop("PATH", None) except Exception as e:
console.print(f"[red]✗[/red] Validation failed")
# Check access keys console.print(f" [dim]{str(e).strip()}[/dim]")
access_keys = get_llm_provider_access_keys(plano_config_file=plano_config_file) sys.exit(1)
access_keys = set(access_keys)
access_keys = [item[1:] if item.startswith("$") else item for item in access_keys]
missing_keys = []
if access_keys:
if file:
app_env_file = os.path.join(os.path.dirname(os.path.abspath(file)), ".env")
else: else:
app_env_file = os.path.abspath(os.path.join(path, ".env")) with Status(
"[dim]Validating configuration (Docker)[/dim]",
spinner="dots",
spinner_style="dim",
):
(
validation_return_code,
_,
validation_stderr,
) = docker_validate_plano_schema(plano_config_file)
if not os.path.exists(app_env_file): if validation_return_code != 0:
for access_key in access_keys: console.print(f"[red]✗[/red] Validation failed")
if env.get(access_key) is None: if validation_stderr:
missing_keys.append(access_key) console.print(f" [dim]{validation_stderr.strip()}[/dim]")
else:
env_stage[access_key] = env.get(access_key)
else:
env_file_dict = load_env_file_to_dict(app_env_file)
for access_key in access_keys:
if env_file_dict.get(access_key) is None:
missing_keys.append(access_key)
else:
env_stage[access_key] = env_file_dict[access_key]
if missing_keys:
_print_missing_keys(console, missing_keys)
sys.exit(1)
# Pass log level to the Docker container — supervisord uses LOG_LEVEL
# to set RUST_LOG (brightstaff) and envoy component log levels
env_stage["LOG_LEVEL"] = os.environ.get("LOG_LEVEL", "info")
# Start the local OTLP trace collector if --with-tracing is set
trace_server = None
if with_tracing:
if _is_port_in_use(tracing_port):
# A listener is already running (e.g. `planoai trace listen`)
console.print(
f"[green]✓[/green] Trace collector already running on port [cyan]{tracing_port}[/cyan]"
)
else:
try:
trace_server = start_trace_listener_background(grpc_port=tracing_port)
console.print(
f"[green]✓[/green] Trace collector listening on [cyan]0.0.0.0:{tracing_port}[/cyan]"
)
except Exception as e:
console.print(
f"[red]✗[/red] Failed to start trace collector on port {tracing_port}: {e}"
)
console.print(
f"\n[dim]Check if another process is using port {tracing_port}:[/dim]"
)
console.print(f" [cyan]lsof -i :{tracing_port}[/cyan]")
console.print(f"\n[dim]Or use a different port:[/dim]")
console.print(
f" [cyan]planoai up --with-tracing --tracing-port 4318[/cyan]\n"
)
sys.exit(1) sys.exit(1)
# Update the OTEL endpoint so the gateway sends traces to the right port log.info("Configuration valid")
tracing_host = "host.docker.internal" if docker else "localhost"
otel_endpoint = f"http://{tracing_host}:{tracing_port}"
env_stage["OTEL_TRACING_GRPC_ENDPOINT"] = otel_endpoint
env.update(env_stage) # Set up environment
try: default_otel = (
if not docker: DEFAULT_OTEL_TRACING_GRPC_ENDPOINT
from planoai.native_runner import start_native if docker
else DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT
)
env_stage = {
"OTEL_TRACING_GRPC_ENDPOINT": default_otel,
}
env = os.environ.copy()
env.pop("PATH", None)
start_native( # Check access keys
plano_config_file, env, foreground=foreground, with_tracing=with_tracing access_keys = get_llm_provider_access_keys(plano_config_file=plano_config_file)
) access_keys = set(access_keys)
else: access_keys = [
start_plano(plano_config_file, env, foreground=foreground) item[1:] if item.startswith("$") else item for item in access_keys
]
# When tracing is enabled but --foreground is not, keep the process missing_keys = []
# alive so the OTLP collector continues to receive spans. if access_keys:
if trace_server is not None and not foreground: if file:
console.print( app_env_file = os.path.join(
f"[dim]Plano is running. Trace collector active on port {tracing_port}. Press Ctrl+C to stop.[/dim]" os.path.dirname(os.path.abspath(file)), ".env"
) )
trace_server.wait_for_termination() else:
except KeyboardInterrupt: app_env_file = os.path.abspath(os.path.join(path, ".env"))
if trace_server is not None:
console.print(f"\n[dim]Stopping trace collector...[/dim]") if not os.path.exists(app_env_file):
finally: for access_key in access_keys:
if trace_server is not None: if env.get(access_key) is None:
trace_server.stop(grace=2) missing_keys.append(access_key)
else:
env_stage[access_key] = env.get(access_key)
else:
env_file_dict = load_env_file_to_dict(app_env_file)
for access_key in access_keys:
if env_file_dict.get(access_key) is None:
missing_keys.append(access_key)
else:
env_stage[access_key] = env_file_dict[access_key]
if missing_keys:
_print_missing_keys(console, missing_keys)
sys.exit(1)
# Pass log level to the Docker container — supervisord uses LOG_LEVEL
# to set RUST_LOG (brightstaff) and envoy component log levels
env_stage["LOG_LEVEL"] = os.environ.get("LOG_LEVEL", "info")
# Start the local OTLP trace collector if --with-tracing is set
trace_server = None
if with_tracing:
if _is_port_in_use(tracing_port):
# A listener is already running (e.g. `planoai trace listen`)
console.print(
f"[green]✓[/green] Trace collector already running on port [cyan]{tracing_port}[/cyan]"
)
else:
try:
trace_server = start_trace_listener_background(
grpc_port=tracing_port
)
console.print(
f"[green]✓[/green] Trace collector listening on [cyan]0.0.0.0:{tracing_port}[/cyan]"
)
except Exception as e:
console.print(
f"[red]✗[/red] Failed to start trace collector on port {tracing_port}: {e}"
)
console.print(
f"\n[dim]Check if another process is using port {tracing_port}:[/dim]"
)
console.print(f" [cyan]lsof -i :{tracing_port}[/cyan]")
console.print(f"\n[dim]Or use a different port:[/dim]")
console.print(
f" [cyan]planoai up --with-tracing --tracing-port 4318[/cyan]\n"
)
sys.exit(1)
# Update the OTEL endpoint so the gateway sends traces to the right port
tracing_host = "host.docker.internal" if docker else "localhost"
otel_endpoint = f"http://{tracing_host}:{tracing_port}"
env_stage["OTEL_TRACING_GRPC_ENDPOINT"] = otel_endpoint
env.update(env_stage)
try:
if not docker:
from planoai.native_runner import start_native
if not verbose:
with Status(
f"[{PLANO_COLOR}]Starting Plano...[/{PLANO_COLOR}]",
spinner="dots",
) as status:
def _update_progress(message: str) -> None:
console.print(f"[dim]{message}[/dim]")
start_native(
plano_config_file,
env,
foreground=foreground,
with_tracing=with_tracing,
progress_callback=_update_progress,
)
console.print("")
console.print(
"[green]✓[/green] Plano is running! [dim](native mode)[/dim]"
)
log_dir = os.path.join(PLANO_RUN_DIR, "logs")
console.print(f"Logs: {log_dir}")
console.print("Run 'planoai down' to stop.")
else:
start_native(
plano_config_file,
env,
foreground=foreground,
with_tracing=with_tracing,
)
else:
start_plano(plano_config_file, env, foreground=foreground)
# When tracing is enabled but --foreground is not, keep the process
# alive so the OTLP collector continues to receive spans.
if trace_server is not None and not foreground:
console.print(
f"[dim]Plano is running. Trace collector active on port {tracing_port}. Press Ctrl+C to stop.[/dim]"
)
trace_server.wait_for_termination()
except KeyboardInterrupt:
if trace_server is not None:
console.print(f"\n[dim]Stopping trace collector...[/dim]")
finally:
if trace_server is not None:
trace_server.stop(grace=2)
@click.command() @click.command()
@ -459,25 +517,46 @@ def up(file, path, foreground, with_tracing, tracing_port, docker):
help="Stop a Docker-based Plano instance.", help="Stop a Docker-based Plano instance.",
is_flag=True, is_flag=True,
) )
def down(docker): @click.option(
"--verbose",
"-v",
default=False,
help="Show detailed shutdown logs with timestamps.",
is_flag=True,
)
def down(docker, verbose):
"""Stops Plano.""" """Stops Plano."""
console = _console() console = _console()
_print_cli_header(console) _print_cli_header(console)
if not docker: with _temporary_cli_log_level("warning" if not verbose else None):
from planoai.native_runner import stop_native if not docker:
from planoai.native_runner import stop_native
with console.status( with console.status(
f"[{PLANO_COLOR}]Shutting down Plano...[/{PLANO_COLOR}]", f"[{PLANO_COLOR}]Shutting down Plano...[/{PLANO_COLOR}]",
spinner="dots", spinner="dots",
): ):
stop_native() stopped = stop_native()
else: if not verbose:
with console.status( if stopped:
f"[{PLANO_COLOR}]Shutting down Plano (Docker)...[/{PLANO_COLOR}]", console.print(
spinner="dots", "[green]✓[/green] Plano stopped! [dim](native mode)[/dim]"
): )
stop_docker_container() else:
console.print(
"[dim]No Plano instance was running (native mode)[/dim]"
)
else:
with console.status(
f"[{PLANO_COLOR}]Shutting down Plano (Docker)...[/{PLANO_COLOR}]",
spinner="dots",
):
stop_docker_container()
if not verbose:
console.print(
"[green]✓[/green] Plano stopped! [dim](docker mode)[/dim]"
)
@click.command() @click.command()

View file

@ -6,6 +6,7 @@ import signal
import subprocess import subprocess
import sys import sys
import time import time
from collections.abc import Callable
from planoai.consts import ( from planoai.consts import (
NATIVE_PID_FILE, NATIVE_PID_FILE,
@ -157,7 +158,13 @@ def render_native_config(plano_config_file, env, with_tracing=False):
return envoy_config_path, plano_config_rendered_path return envoy_config_path, plano_config_rendered_path
def start_native(plano_config_file, env, foreground=False, with_tracing=False): def start_native(
plano_config_file,
env,
foreground=False,
with_tracing=False,
progress_callback: Callable[[str], None] | None = None,
):
"""Start Envoy and brightstaff natively.""" """Start Envoy and brightstaff natively."""
from planoai.core import _get_gateway_ports from planoai.core import _get_gateway_ports
@ -174,6 +181,8 @@ def start_native(plano_config_file, env, foreground=False, with_tracing=False):
) )
log.info("Configuration rendered") log.info("Configuration rendered")
if progress_callback:
progress_callback("Configuration valid...")
log_dir = os.path.join(PLANO_RUN_DIR, "logs") log_dir = os.path.join(PLANO_RUN_DIR, "logs")
os.makedirs(log_dir, exist_ok=True) os.makedirs(log_dir, exist_ok=True)
@ -194,6 +203,8 @@ def start_native(plano_config_file, env, foreground=False, with_tracing=False):
os.path.join(log_dir, "brightstaff.log"), os.path.join(log_dir, "brightstaff.log"),
) )
log.info(f"Started brightstaff (PID {brightstaff_pid})") log.info(f"Started brightstaff (PID {brightstaff_pid})")
if progress_callback:
progress_callback(f"Started brightstaff (PID: {brightstaff_pid})...")
# Start envoy # Start envoy
envoy_pid = _daemon_exec( envoy_pid = _daemon_exec(
@ -210,6 +221,8 @@ def start_native(plano_config_file, env, foreground=False, with_tracing=False):
os.path.join(log_dir, "envoy.log"), os.path.join(log_dir, "envoy.log"),
) )
log.info(f"Started envoy (PID {envoy_pid})") log.info(f"Started envoy (PID {envoy_pid})")
if progress_callback:
progress_callback(f"Started envoy (PID: {envoy_pid})...")
# Save PIDs # Save PIDs
os.makedirs(PLANO_RUN_DIR, exist_ok=True) os.makedirs(PLANO_RUN_DIR, exist_ok=True)
@ -225,6 +238,8 @@ def start_native(plano_config_file, env, foreground=False, with_tracing=False):
# Health check # Health check
gateway_ports = _get_gateway_ports(plano_config_file) gateway_ports = _get_gateway_ports(plano_config_file)
log.info("Waiting for listeners to become healthy...") log.info("Waiting for listeners to become healthy...")
if progress_callback:
progress_callback("Waiting for listeners to become healthy...")
start_time = time.time() start_time = time.time()
timeout = 60 timeout = 60
@ -353,10 +368,15 @@ def _kill_pid(pid):
def stop_native(): def stop_native():
"""Stop natively-running Envoy and brightstaff processes.""" """Stop natively-running Envoy and brightstaff processes.
Returns:
bool: True if at least one process was running and received a stop signal,
False if no running native Plano process was found.
"""
if not os.path.exists(NATIVE_PID_FILE): if not os.path.exists(NATIVE_PID_FILE):
log.info("No native Plano instance found (PID file missing).") log.info("No native Plano instance found (PID file missing).")
return return False
with open(NATIVE_PID_FILE, "r") as f: with open(NATIVE_PID_FILE, "r") as f:
pids = json.load(f) pids = json.load(f)
@ -364,12 +384,14 @@ def stop_native():
envoy_pid = pids.get("envoy_pid") envoy_pid = pids.get("envoy_pid")
brightstaff_pid = pids.get("brightstaff_pid") brightstaff_pid = pids.get("brightstaff_pid")
had_running_process = False
for name, pid in [("envoy", envoy_pid), ("brightstaff", brightstaff_pid)]: for name, pid in [("envoy", envoy_pid), ("brightstaff", brightstaff_pid)]:
if pid is None: if pid is None:
continue continue
try: try:
os.kill(pid, signal.SIGTERM) os.kill(pid, signal.SIGTERM)
log.info(f"Sent SIGTERM to {name} (PID {pid})") log.info(f"Sent SIGTERM to {name} (PID {pid})")
had_running_process = True
except ProcessLookupError: except ProcessLookupError:
log.info(f"{name} (PID {pid}) already stopped") log.info(f"{name} (PID {pid}) already stopped")
continue continue
@ -394,7 +416,11 @@ def stop_native():
pass pass
os.unlink(NATIVE_PID_FILE) os.unlink(NATIVE_PID_FILE)
log.info("Plano stopped (native mode).") if had_running_process:
log.info("Plano stopped (native mode).")
else:
log.info("No native Plano instance was running.")
return had_running_process
def native_validate_config(plano_config_file): def native_validate_config(plano_config_file):