Improve planoai up/down CLI progress output

This commit is contained in:
Spherrrical 2026-03-31 13:21:21 -07:00
parent 82f34f82f2
commit ea3e723c37
2 changed files with 263 additions and 158 deletions

View file

@ -3,6 +3,8 @@ import os
import multiprocessing
import subprocess
import sys
import contextlib
import logging
import rich_click as click
from planoai import targets
@ -33,6 +35,7 @@ from planoai.consts import (
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT,
DEFAULT_NATIVE_OTEL_TRACING_GRPC_ENDPOINT,
NATIVE_PID_FILE,
PLANO_RUN_DIR,
PLANO_DOCKER_IMAGE,
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:
console.print(f"\n[red]✗[/red] [red]Missing API keys![/red]\n")
for key in missing_keys:
@ -293,163 +310,204 @@ def build(docker):
help="Run Plano inside Docker instead of natively.",
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."""
from rich.status import Status
console = _console()
_print_cli_header(console)
# Use the utility function to find config file
plano_config_file = find_config_file(path, file)
with _temporary_cli_log_level("warning" if not verbose else None):
# Use the utility function to find config file
plano_config_file = find_config_file(path, file)
# Check if the file exists
if not os.path.exists(plano_config_file):
console.print(
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]")
# Check if the file exists
if not os.path.exists(plano_config_file):
console.print(
f"[red]✗[/red] Config file not found: [dim]{plano_config_file}[/dim]"
)
sys.exit(1)
log.info("Configuration valid")
if not docker:
from planoai.native_runner import native_validate_config
# Set up environment
default_otel = (
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT
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)
# Check access keys
access_keys = get_llm_provider_access_keys(plano_config_file=plano_config_file)
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")
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:
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):
for access_key in access_keys:
if env.get(access_key) is None:
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"
)
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)
# 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
log.info("Configuration valid")
env.update(env_stage)
try:
if not docker:
from planoai.native_runner import start_native
# Set up environment
default_otel = (
DEFAULT_OTEL_TRACING_GRPC_ENDPOINT
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(
plano_config_file, env, foreground=foreground, with_tracing=with_tracing
)
else:
start_plano(plano_config_file, env, foreground=foreground)
# Check access keys
access_keys = get_llm_provider_access_keys(plano_config_file=plano_config_file)
access_keys = set(access_keys)
access_keys = [
item[1:] if item.startswith("$") else item for item in access_keys
]
# 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)
missing_keys = []
if access_keys:
if file:
app_env_file = os.path.join(
os.path.dirname(os.path.abspath(file)), ".env"
)
else:
app_env_file = os.path.abspath(os.path.join(path, ".env"))
if not os.path.exists(app_env_file):
for access_key in access_keys:
if env.get(access_key) is None:
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()
@ -459,25 +517,46 @@ def up(file, path, foreground, with_tracing, tracing_port, docker):
help="Stop a Docker-based Plano instance.",
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."""
console = _console()
_print_cli_header(console)
if not docker:
from planoai.native_runner import stop_native
with _temporary_cli_log_level("warning" if not verbose else None):
if not docker:
from planoai.native_runner import stop_native
with console.status(
f"[{PLANO_COLOR}]Shutting down Plano...[/{PLANO_COLOR}]",
spinner="dots",
):
stop_native()
else:
with console.status(
f"[{PLANO_COLOR}]Shutting down Plano (Docker)...[/{PLANO_COLOR}]",
spinner="dots",
):
stop_docker_container()
with console.status(
f"[{PLANO_COLOR}]Shutting down Plano...[/{PLANO_COLOR}]",
spinner="dots",
):
stopped = stop_native()
if not verbose:
if stopped:
console.print(
"[green]✓[/green] Plano stopped! [dim](native mode)[/dim]"
)
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()

View file

@ -6,6 +6,7 @@ import signal
import subprocess
import sys
import time
from collections.abc import Callable
from planoai.consts import (
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
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."""
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")
if progress_callback:
progress_callback("Configuration valid...")
log_dir = os.path.join(PLANO_RUN_DIR, "logs")
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"),
)
log.info(f"Started brightstaff (PID {brightstaff_pid})")
if progress_callback:
progress_callback(f"Started brightstaff (PID: {brightstaff_pid})...")
# Start envoy
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"),
)
log.info(f"Started envoy (PID {envoy_pid})")
if progress_callback:
progress_callback(f"Started envoy (PID: {envoy_pid})...")
# Save PIDs
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
gateway_ports = _get_gateway_ports(plano_config_file)
log.info("Waiting for listeners to become healthy...")
if progress_callback:
progress_callback("Waiting for listeners to become healthy...")
start_time = time.time()
timeout = 60
@ -353,10 +368,15 @@ def _kill_pid(pid):
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):
log.info("No native Plano instance found (PID file missing).")
return
return False
with open(NATIVE_PID_FILE, "r") as f:
pids = json.load(f)
@ -364,12 +384,14 @@ def stop_native():
envoy_pid = pids.get("envoy_pid")
brightstaff_pid = pids.get("brightstaff_pid")
had_running_process = False
for name, pid in [("envoy", envoy_pid), ("brightstaff", brightstaff_pid)]:
if pid is None:
continue
try:
os.kill(pid, signal.SIGTERM)
log.info(f"Sent SIGTERM to {name} (PID {pid})")
had_running_process = True
except ProcessLookupError:
log.info(f"{name} (PID {pid}) already stopped")
continue
@ -394,7 +416,11 @@ def stop_native():
pass
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):