300 lines
11 KiB
Python
300 lines
11 KiB
Python
"""``EvoSci deploy`` — start a standalone langgraph dev server.
|
|
|
|
Hosts the fully-equipped EvoScientist main agent (MCP + async sub-agents)
|
|
for consumption by external LangChain-compatible UIs (deep-agents-ui,
|
|
agent-chat-ui, LangSmith Studio) and SDK clients.
|
|
|
|
Differs from ``EvoSci`` / ``EvoSci serve``: no in-process CLI agent,
|
|
no session DB, no channel runtime, no TUI. The terminal only shows
|
|
startup progress, the Ready banner, and then blocks until Ctrl+C.
|
|
|
|
Mode dispatch happens via the ``EVOSCIENTIST_DEPLOY_MODE`` env var
|
|
injected by ``start_langgraph_dev``: ``full`` for the deploy subprocess
|
|
(this command), ``stripped`` for CLI/serve subprocesses, unset for the
|
|
parent process. The subprocess reads this at module-load time
|
|
(``langgraph_dev/manager.py``) to flip ``_ASYNC_SUBAGENTS_AVAILABLE``,
|
|
and the agent build code (``EvoScientist.py:_get_default_agent``)
|
|
loads or skips MCP based on the value.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import os
|
|
import signal
|
|
import threading
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import typer # type: ignore[import-untyped]
|
|
from rich.panel import Panel
|
|
from rich.text import Text
|
|
|
|
from ..cli._app import app
|
|
from ..stream.console import console
|
|
|
|
|
|
@app.command()
|
|
def deploy(
|
|
workdir: str | None = typer.Option(
|
|
None,
|
|
"--workdir",
|
|
help="Workspace directory (default: config.default_workdir or cwd)",
|
|
),
|
|
port: int | None = typer.Option(
|
|
None,
|
|
"--port",
|
|
help="Port for langgraph dev (default: config.langgraph_dev_port = 6174)",
|
|
),
|
|
tunnel: bool = typer.Option(
|
|
False,
|
|
"--tunnel",
|
|
help="Expose the server over a public Cloudflare tunnel (no auth — "
|
|
"anyone with the URL can drive the agent; trusted use only)",
|
|
),
|
|
debug: bool = typer.Option(
|
|
False,
|
|
"--debug",
|
|
help="Enable debug logging",
|
|
),
|
|
):
|
|
"""Deploy EvoScientist main agent as a standalone LangGraph dev server.
|
|
|
|
Starts ``langgraph dev`` in deploy mode (full MCP + async sub-agents).
|
|
Connect any LangChain-compatible UI or SDK client to the printed
|
|
endpoint. Press Ctrl+C to stop.
|
|
"""
|
|
from ..config import apply_config_to_env, get_effective_config
|
|
from ..langgraph_dev.manager import (
|
|
_DEFAULT_PORT,
|
|
RUNTIME,
|
|
_is_port_occupied,
|
|
is_langgraph_dev_running,
|
|
read_tunnel_url,
|
|
start_langgraph_dev,
|
|
stop_langgraph_dev,
|
|
)
|
|
|
|
# 1. Load config (no CLI overrides here — deploy is opinionated about
|
|
# full MCP + async; user-facing flags are workspace/port/debug only).
|
|
cli_overrides: dict[str, Any] = {}
|
|
if debug:
|
|
cli_overrides["log_level"] = "DEBUG"
|
|
config = get_effective_config(cli_overrides)
|
|
if debug:
|
|
os.environ["EVOSCIENTIST_LOG_LEVEL"] = "DEBUG"
|
|
from ..cli.commands import _configure_logging
|
|
|
|
_configure_logging()
|
|
apply_config_to_env(config)
|
|
|
|
# 2. Resolve workspace (CLI > config.default_workdir > cwd)
|
|
if workdir:
|
|
ws = os.path.abspath(os.path.expanduser(workdir))
|
|
elif config.default_workdir:
|
|
ws = os.path.abspath(os.path.expanduser(config.default_workdir))
|
|
else:
|
|
ws = os.getcwd()
|
|
# Subprocess inherits this path via EVOSCIENTIST_WORKSPACE_DIR (set inside
|
|
# start_langgraph_dev). Ensure the dir exists; do NOT mutate the parent
|
|
# process's paths module state — the deploy parent has no in-process agent.
|
|
os.makedirs(ws, exist_ok=True)
|
|
|
|
# 3. Resolve port (explicit None check — don't treat --port 0 as "unset"),
|
|
# then validate range so misconfigurations fail fast with a clear message
|
|
# instead of an opaque socket error from langgraph dev later.
|
|
effective_port = (
|
|
int(getattr(config, "langgraph_dev_port", _DEFAULT_PORT))
|
|
if port is None
|
|
else port
|
|
)
|
|
if not (1 <= effective_port <= 65535):
|
|
console.print(
|
|
f"[red]Invalid port {effective_port}. Use an integer in [1, 65535].[/red]"
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
# 4. Pre-flight port check — refuse to start if a non-EvoSci process is
|
|
# holding the port. If an existing EvoSci langgraph dev is already up,
|
|
# also refuse (deploy is the "primary server" — running multiple on the
|
|
# same port is a configuration error).
|
|
if _is_port_occupied(effective_port):
|
|
if is_langgraph_dev_running(port=effective_port):
|
|
console.print(
|
|
f"[red]Port {effective_port} is already serving a langgraph dev "
|
|
f"instance.[/red]"
|
|
)
|
|
console.print(
|
|
"[dim]Stop the existing EvoSci/serve session first, or use "
|
|
"[bold]--port[/bold] to deploy on a different port.[/dim]"
|
|
)
|
|
else:
|
|
console.print(
|
|
f"[red]Port {effective_port} is occupied by another process.[/red]"
|
|
)
|
|
console.print(
|
|
f"[dim]Run [bold]lsof -i :{effective_port}[/bold] to inspect, "
|
|
f"or use [bold]--port[/bold] to pick a different port.[/dim]"
|
|
)
|
|
raise typer.Exit(1)
|
|
|
|
# 5. Startup banner
|
|
_auth_label = _describe_auth(config)
|
|
console.print(
|
|
Panel(
|
|
Text.from_markup(
|
|
f"[bold]Workspace:[/bold] {_shorten(ws)}\n"
|
|
f"[bold]Port:[/bold] {effective_port}\n"
|
|
f"[bold]Auth:[/bold] {_auth_label}"
|
|
),
|
|
title="[bold cyan]EvoScientist Deploy[/bold cyan]",
|
|
border_style="cyan",
|
|
)
|
|
)
|
|
if config.dangerous_mode:
|
|
from ..cli._constants import (
|
|
DANGEROUS_BANNER_LABEL,
|
|
DANGEROUS_BANNER_MESSAGE,
|
|
)
|
|
|
|
console.print(
|
|
f"[bold white on red] ⚠ {DANGEROUS_BANNER_LABEL} [/bold white on red] "
|
|
f"[bold red]{DANGEROUS_BANNER_MESSAGE}[/bold red]"
|
|
)
|
|
|
|
if tunnel:
|
|
console.print(
|
|
"[bold white on red] ⚠ PUBLIC TUNNEL [/bold white on red] "
|
|
"[bold red]Public URL, no auth — share only with people you "
|
|
"trust.[/bold red]"
|
|
)
|
|
|
|
# 6. ccproxy lifecycle (only if any provider uses OAuth)
|
|
_ccproxy_proc = None
|
|
if config.anthropic_auth_mode == "oauth" or config.openai_auth_mode == "oauth":
|
|
try:
|
|
from ..ccproxy_manager import maybe_start_ccproxy, stop_ccproxy
|
|
|
|
with console.status(
|
|
"[dim]Starting ccproxy (OAuth proxy)...[/dim]", spinner="dots"
|
|
):
|
|
_ccproxy_proc = maybe_start_ccproxy(config)
|
|
if _ccproxy_proc:
|
|
atexit.register(stop_ccproxy, _ccproxy_proc)
|
|
console.print("[green]✓[/green] ccproxy started")
|
|
except RuntimeError as exc:
|
|
console.print(f"[red]ccproxy startup failed:[/red] {exc}")
|
|
raise typer.Exit(1) from exc
|
|
|
|
# 7. Start langgraph dev (deploy mode → full MCP + async)
|
|
jobs_per_worker = int(getattr(config, "langgraph_dev_jobs_per_worker", 10))
|
|
file_persistence = bool(getattr(config, "langgraph_dev_file_persistence", True))
|
|
try:
|
|
with console.status(
|
|
"[dim]Starting langgraph dev (deploy mode: MCP + async)...[/dim]",
|
|
spinner="dots",
|
|
):
|
|
proc = start_langgraph_dev(
|
|
workspace_dir=Path(ws),
|
|
port=effective_port,
|
|
file_persistence=file_persistence,
|
|
jobs_per_worker=jobs_per_worker,
|
|
deploy_mode=True,
|
|
tunnel=tunnel,
|
|
)
|
|
atexit.register(stop_langgraph_dev, proc)
|
|
except Exception as exc:
|
|
console.print(f"[red]langgraph dev startup failed:[/red] {exc}")
|
|
raise typer.Exit(1) from exc
|
|
|
|
# start_langgraph_dev already health-polled before returning; if we got
|
|
# here, the subprocess is up.
|
|
console.print("[green]✓[/green] langgraph dev ready")
|
|
|
|
# 8b. Cloudflare tunnel URL — the local server is healthy, but cloudflared
|
|
# establishes the public tunnel a few seconds later and prints the random
|
|
# URL into the log. Poll for it so we can surface it in the ready banner.
|
|
public_url: str | None = None
|
|
if tunnel:
|
|
with console.status(
|
|
"[dim]Waiting for Cloudflare tunnel URL...[/dim]", spinner="dots"
|
|
):
|
|
public_url = read_tunnel_url()
|
|
if public_url:
|
|
console.print("[green]✓[/green] tunnel up")
|
|
else:
|
|
console.print(
|
|
"[yellow]⚠ Tunnel URL not detected within the wait window. "
|
|
f"Check the log ({_shorten(str(RUNTIME.log_file))}) for a "
|
|
"trycloudflare.com URL.[/yellow]"
|
|
)
|
|
|
|
# 9. Ready banner
|
|
log_hint = _shorten(str(RUNTIME.log_file))
|
|
public_line = f"[bold]Public URL:[/bold] {public_url}\n" if public_url else ""
|
|
console.print(
|
|
Panel(
|
|
Text.from_markup(
|
|
f"[bold]Endpoint:[/bold] "
|
|
f"http://localhost:{effective_port}\n"
|
|
f"{public_line}"
|
|
f"[bold]Assistant ID:[/bold] EvoScientist\n"
|
|
f"[bold]Connect via:[/bold] any LangChain SDK / "
|
|
f"LangGraph-compatible UI\n"
|
|
f"[bold]Logs:[/bold] {log_hint}\n\n"
|
|
f"[dim]Press Ctrl+C to stop.[/dim]"
|
|
),
|
|
title="[bold green]✓ Ready[/bold green]",
|
|
border_style="green",
|
|
)
|
|
)
|
|
|
|
# 10. Block on signal — mirror serve's dual-gate (threading.Event +
|
|
# explicit SIGINT/SIGTERM handlers) so SIGTERM (no default raise) also
|
|
# triggers clean shutdown.
|
|
shutdown_event = threading.Event()
|
|
|
|
def _handle_shutdown(signum: int, _frame: Any) -> None:
|
|
shutdown_event.set()
|
|
if signum == signal.SIGINT:
|
|
signal.default_int_handler(signum, _frame)
|
|
|
|
_orig_sigint = signal.signal(signal.SIGINT, _handle_shutdown)
|
|
_orig_sigterm = signal.signal(signal.SIGTERM, _handle_shutdown)
|
|
|
|
try:
|
|
while not shutdown_event.is_set():
|
|
shutdown_event.wait(timeout=0.5)
|
|
except KeyboardInterrupt:
|
|
shutdown_event.set()
|
|
finally:
|
|
signal.signal(signal.SIGINT, _orig_sigint)
|
|
signal.signal(signal.SIGTERM, _orig_sigterm)
|
|
# stop_langgraph_dev + stop_ccproxy run via atexit during interpreter
|
|
# shutdown, so subprocess teardown happens AFTER this print returns.
|
|
# Don't claim "Stopped." here — that would be a lie until atexit fires.
|
|
console.print(
|
|
"\n[dim]Shutting down (background cleanup may take a few seconds)...[/dim]"
|
|
)
|
|
|
|
|
|
def _describe_auth(config: Any) -> str:
|
|
"""Render a one-line auth summary for the startup banner."""
|
|
anth = getattr(config, "anthropic_auth_mode", "api_key")
|
|
oai = getattr(config, "openai_auth_mode", "api_key")
|
|
if anth == "oauth" and oai == "oauth":
|
|
return "OAuth (Anthropic + OpenAI via ccproxy)"
|
|
if anth == "oauth":
|
|
return "OAuth (Anthropic via ccproxy) + API key (OpenAI)"
|
|
if oai == "oauth":
|
|
return "API key (Anthropic) + OAuth (OpenAI via ccproxy)"
|
|
return "API key"
|
|
|
|
|
|
def _shorten(path: str) -> str:
|
|
"""Replace ``$HOME`` prefix with ``~`` for compact display."""
|
|
home = os.path.expanduser("~")
|
|
if path.startswith(home):
|
|
return "~" + path[len(home) :]
|
|
return path
|