fix(cli): share on-demand gateway lifecycle
This commit is contained in:
+12
-1
@@ -14,6 +14,7 @@ from rich.console import Console
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.gateway import (
|
||||
GatewayClientLease,
|
||||
GatewayRuntime,
|
||||
GatewayRuntimePaths,
|
||||
GatewayStartOptions,
|
||||
@@ -166,10 +167,16 @@ def create_gateway_app(
|
||||
loaded_config=cfg,
|
||||
)
|
||||
)
|
||||
if result.ok or result.message == "gateway_already_running":
|
||||
GatewayClientLease(runtime, kind="gateway-background").mark_persistent()
|
||||
if result.ok:
|
||||
console.print("[green]Gateway started in the background.[/green]")
|
||||
print_status(result.status)
|
||||
return
|
||||
if result.message == "gateway_already_running":
|
||||
console.print("[yellow]Gateway is already running in the background.[/yellow]")
|
||||
print_status(result.status)
|
||||
return
|
||||
console.print(f"[yellow]Gateway was not started: {result.message}[/yellow]")
|
||||
print_status(result.status)
|
||||
raise typer.Exit(1)
|
||||
@@ -222,7 +229,10 @@ def create_gateway_app(
|
||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||
) -> None:
|
||||
"""Stop the background gateway."""
|
||||
result = runtime_for_instance(workspace=workspace, config=config).stop(timeout_s=timeout)
|
||||
runtime = runtime_for_instance(workspace=workspace, config=config)
|
||||
result = runtime.stop(timeout_s=timeout)
|
||||
if result.ok or result.message == "gateway_not_running":
|
||||
GatewayClientLease(runtime, kind="gateway-stop").clear()
|
||||
if result.ok:
|
||||
console.print("[green]Gateway stopped.[/green]")
|
||||
else:
|
||||
@@ -257,6 +267,7 @@ def create_gateway_app(
|
||||
timeout_s=timeout,
|
||||
)
|
||||
if result.ok:
|
||||
GatewayClientLease(runtime, kind="gateway-restart").mark_persistent()
|
||||
console.print("[green]Gateway restarted in the background.[/green]")
|
||||
print_status(result.status)
|
||||
return
|
||||
|
||||
+95
-77
@@ -14,7 +14,7 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from nanobot import __version__
|
||||
from nanobot.cli.runtime_config import _model_display
|
||||
@@ -24,9 +24,12 @@ from nanobot.cli.webui_support import (
|
||||
_webui_endpoint_reachable,
|
||||
webui_bootstrap_secret,
|
||||
)
|
||||
from nanobot.config.paths import get_data_dir, is_default_workspace
|
||||
from nanobot.config.paths import get_data_dir
|
||||
from nanobot.config.schema import Config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.gateway import GatewayClientLease
|
||||
|
||||
|
||||
class TuiUnavailableError(RuntimeError):
|
||||
"""Raised when the native TypeScript TUI cannot run on this installation."""
|
||||
@@ -39,6 +42,7 @@ class TuiSessionError(ValueError):
|
||||
@dataclass(frozen=True)
|
||||
class _GatewayHandle:
|
||||
base_url: str
|
||||
lease: GatewayClientLease | None = None
|
||||
|
||||
|
||||
def launch_tui(
|
||||
@@ -58,35 +62,39 @@ def launch_tui(
|
||||
config_path=config_path,
|
||||
workspace_override=workspace_override,
|
||||
)
|
||||
bootstrap = _fetch_bootstrap(
|
||||
gateway.base_url,
|
||||
secret=webui_bootstrap_secret(config),
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"NANOBOT_TUI_WS_URL": _authenticated_ws_url(bootstrap),
|
||||
"NANOBOT_TUI_API_URL": gateway.base_url,
|
||||
"NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""),
|
||||
"NANOBOT_TUI_MODEL": _model_display(config)[0],
|
||||
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
|
||||
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path),
|
||||
"NANOBOT_TUI_VERSION": __version__,
|
||||
"NANOBOT_TUI_ACCESS": (
|
||||
"workspace access" if config.tools.restrict_to_workspace else "full access"
|
||||
),
|
||||
"NANOBOT_TUI_THEME": theme,
|
||||
}
|
||||
)
|
||||
env["NANOBOT_TUI_STATE_PATH"] = str(state_path)
|
||||
if chat_id:
|
||||
env["NANOBOT_TUI_CHAT_ID"] = chat_id
|
||||
else:
|
||||
env.pop("NANOBOT_TUI_CHAT_ID", None)
|
||||
try:
|
||||
bootstrap = _fetch_bootstrap(
|
||||
gateway.base_url,
|
||||
secret=webui_bootstrap_secret(config),
|
||||
)
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"NANOBOT_TUI_WS_URL": _authenticated_ws_url(bootstrap),
|
||||
"NANOBOT_TUI_API_URL": gateway.base_url,
|
||||
"NANOBOT_TUI_API_TOKEN": str(bootstrap.get("api_token") or ""),
|
||||
"NANOBOT_TUI_MODEL": _model_display(config)[0],
|
||||
"NANOBOT_TUI_MODEL_PRESET": config.agents.defaults.model_preset or "default",
|
||||
"NANOBOT_TUI_WORKSPACE": str(config.workspace_path),
|
||||
"NANOBOT_TUI_VERSION": __version__,
|
||||
"NANOBOT_TUI_ACCESS": (
|
||||
"workspace access" if config.tools.restrict_to_workspace else "full access"
|
||||
),
|
||||
"NANOBOT_TUI_THEME": theme,
|
||||
}
|
||||
)
|
||||
env["NANOBOT_TUI_STATE_PATH"] = str(state_path)
|
||||
if chat_id:
|
||||
env["NANOBOT_TUI_CHAT_ID"] = chat_id
|
||||
else:
|
||||
env.pop("NANOBOT_TUI_CHAT_ID", None)
|
||||
return subprocess.run(command, env=env, check=False).returncode
|
||||
except OSError as exc:
|
||||
raise TuiUnavailableError(f"could not start the native TUI: {exc}") from exc
|
||||
finally:
|
||||
lease = getattr(gateway, "lease", None)
|
||||
if lease is not None:
|
||||
lease.release()
|
||||
|
||||
|
||||
def _resolve_tui_command() -> list[str]:
|
||||
@@ -232,7 +240,12 @@ def _ensure_gateway(
|
||||
config_path: Path,
|
||||
workspace_override: str | None,
|
||||
) -> _GatewayHandle:
|
||||
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
|
||||
from nanobot.gateway import (
|
||||
GatewayClientLease,
|
||||
GatewayRuntime,
|
||||
GatewayRuntimePaths,
|
||||
GatewayStartOptions,
|
||||
)
|
||||
|
||||
base_url = _webui_browser_url(config).split("/#/", 1)[0].rstrip("/")
|
||||
workspace_override_path = (
|
||||
@@ -240,65 +253,70 @@ def _ensure_gateway(
|
||||
if workspace_override
|
||||
else None
|
||||
)
|
||||
effective_workspace = config.workspace_path.resolve(strict=False)
|
||||
runtime_workspace = (
|
||||
None if is_default_workspace(effective_workspace) else str(effective_workspace)
|
||||
)
|
||||
runtime = GatewayRuntime(
|
||||
paths=GatewayRuntimePaths.for_instance(
|
||||
data_dir=config_path.parent,
|
||||
workspace=runtime_workspace,
|
||||
config_path=str(config_path),
|
||||
)
|
||||
)
|
||||
status = runtime.status()
|
||||
endpoint_reachable = _webui_endpoint_reachable(base_url)
|
||||
if status.running:
|
||||
if status.port not in {None, config.gateway.port}:
|
||||
raise TuiUnavailableError(
|
||||
"the matching gateway instance is running on a different port; "
|
||||
"restart it or use `nanobot agent --classic`"
|
||||
)
|
||||
if endpoint_reachable:
|
||||
return _GatewayHandle(base_url=base_url)
|
||||
elif endpoint_reachable:
|
||||
raise TuiUnavailableError(
|
||||
"the configured gateway port belongs to a different nanobot instance; "
|
||||
"stop that instance or use `nanobot agent --classic`"
|
||||
)
|
||||
|
||||
result = runtime.start_background(
|
||||
GatewayStartOptions(
|
||||
port=config.gateway.port,
|
||||
workspace=workspace_override_path,
|
||||
config_path=str(config_path),
|
||||
)
|
||||
)
|
||||
started_here = result.ok
|
||||
if not result.ok and result.message != "gateway_already_running":
|
||||
raise TuiUnavailableError(
|
||||
f"could not start the local gateway ({result.message}); logs: {result.status.log_path}"
|
||||
lease = GatewayClientLease(runtime, kind="tui")
|
||||
lease.acquire()
|
||||
try:
|
||||
status = runtime.status()
|
||||
endpoint_reachable = _webui_endpoint_reachable(base_url)
|
||||
if status.running:
|
||||
if status.port not in {None, config.gateway.port}:
|
||||
raise TuiUnavailableError(
|
||||
"the matching gateway instance is running on a different port; "
|
||||
"restart it or use `nanobot agent --classic`"
|
||||
)
|
||||
if endpoint_reachable:
|
||||
return _GatewayHandle(base_url=base_url, lease=lease)
|
||||
elif endpoint_reachable:
|
||||
raise TuiUnavailableError(
|
||||
"the configured gateway port belongs to a different nanobot instance; "
|
||||
"stop that instance or use `nanobot agent --classic`"
|
||||
)
|
||||
|
||||
result = runtime.start_background(
|
||||
GatewayStartOptions(
|
||||
port=config.gateway.port,
|
||||
workspace=workspace_override_path,
|
||||
config_path=str(config_path),
|
||||
)
|
||||
)
|
||||
started_here = result.ok
|
||||
if started_here:
|
||||
lease.mark_ephemeral()
|
||||
if not result.ok and result.message != "gateway_already_running":
|
||||
raise TuiUnavailableError(
|
||||
f"could not start the local gateway ({result.message}); "
|
||||
f"logs: {result.status.log_path}"
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + 20
|
||||
while time.monotonic() < deadline:
|
||||
if _webui_endpoint_reachable(base_url):
|
||||
current = runtime.status()
|
||||
if current.running and current.port in {None, config.gateway.port}:
|
||||
return _GatewayHandle(base_url=base_url)
|
||||
break
|
||||
if not runtime.status().running and not _gateway_health_ready(
|
||||
config.gateway.host,
|
||||
config.gateway.port,
|
||||
):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
deadline = time.monotonic() + 20
|
||||
while time.monotonic() < deadline:
|
||||
if _webui_endpoint_reachable(base_url):
|
||||
current = runtime.status()
|
||||
if current.running and current.port in {None, config.gateway.port}:
|
||||
return _GatewayHandle(base_url=base_url, lease=lease)
|
||||
break
|
||||
if not runtime.status().running and not _gateway_health_ready(
|
||||
config.gateway.host,
|
||||
config.gateway.port,
|
||||
):
|
||||
break
|
||||
time.sleep(0.1)
|
||||
|
||||
if started_here:
|
||||
runtime.stop(timeout_s=5)
|
||||
raise TuiUnavailableError(
|
||||
f"local gateway did not become ready; logs: {result.status.log_path}"
|
||||
)
|
||||
if started_here:
|
||||
runtime.stop(timeout_s=5)
|
||||
raise TuiUnavailableError(
|
||||
f"local gateway did not become ready; logs: {result.status.log_path}"
|
||||
)
|
||||
except BaseException:
|
||||
lease.release(timeout_s=5)
|
||||
raise
|
||||
|
||||
|
||||
def _fetch_bootstrap(base_url: str, *, secret: str) -> dict[str, Any]:
|
||||
|
||||
+107
-71
@@ -7,7 +7,6 @@ from pydantic import ValidationError
|
||||
from rich.console import Console
|
||||
|
||||
from nanobot.cli import terminal as cli_terminal
|
||||
from nanobot.cli.gateway_runtime import _run_gateway
|
||||
from nanobot.cli.runtime_config import (
|
||||
_load_runtime_config,
|
||||
_print_config_error,
|
||||
@@ -27,7 +26,6 @@ from nanobot.cli.webui_support import (
|
||||
_open_webui_browser,
|
||||
_prepare_webui_bundle_for_gateway,
|
||||
_print_foreground_port_conflict,
|
||||
_print_webui_foreground_lifecycle,
|
||||
_resolve_webui_config_path,
|
||||
_run_quick_start_for_webui,
|
||||
_tcp_endpoint_reachable,
|
||||
@@ -101,7 +99,12 @@ def webui(
|
||||
) -> None:
|
||||
"""Prepare the local WebUI, start the gateway, and open the browser workbench."""
|
||||
from nanobot.config.loader import resolve_config_env_vars, save_config
|
||||
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
|
||||
from nanobot.gateway import (
|
||||
GatewayClientLease,
|
||||
GatewayRuntime,
|
||||
GatewayRuntimePaths,
|
||||
GatewayStartOptions,
|
||||
)
|
||||
|
||||
cli_terminal._ensure_interactive_tty_mode()
|
||||
if dev and background:
|
||||
@@ -223,9 +226,16 @@ def webui(
|
||||
config_path=config_arg,
|
||||
)
|
||||
|
||||
if background:
|
||||
_prepare_webui_bundle_for_gateway(runtime_config, mode=webui_bundle_mode)
|
||||
def ensure_shared_gateway(*, client_lease: GatewayClientLease | None = None) -> None:
|
||||
"""Start or refresh the one managed gateway shared by local clients."""
|
||||
_prepare_webui_bundle_for_gateway(
|
||||
runtime_config,
|
||||
mode="skip" if dev else webui_bundle_mode,
|
||||
)
|
||||
result = runtime.start_background(start_options)
|
||||
started_fresh = result.ok
|
||||
if started_fresh and client_lease is not None:
|
||||
client_lease.mark_ephemeral()
|
||||
restarted = False
|
||||
restart_attempted = False
|
||||
if not result.ok and result.message == "gateway_already_running" and changed_webui:
|
||||
@@ -244,6 +254,8 @@ def webui(
|
||||
console.print("[green]Gateway started in the background.[/green]")
|
||||
else:
|
||||
console.print("[yellow]Gateway is already running in the background.[/yellow]")
|
||||
|
||||
def print_shared_gateway_controls() -> None:
|
||||
console.print(
|
||||
"Manage this instance: "
|
||||
f"[cyan]{_gateway_instance_command('status', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||
@@ -257,6 +269,11 @@ def webui(
|
||||
"Stop nanobot: "
|
||||
f"[cyan]{_gateway_instance_command('stop', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||
)
|
||||
|
||||
if background:
|
||||
ensure_shared_gateway()
|
||||
GatewayClientLease(runtime, kind="webui-background").mark_persistent()
|
||||
print_shared_gateway_controls()
|
||||
if not no_open:
|
||||
_open_webui_browser(webui_url)
|
||||
return
|
||||
@@ -264,47 +281,67 @@ def webui(
|
||||
gateway_ready = _gateway_health_ready(runtime_config.gateway.host, effective_gateway_port)
|
||||
webui_ready = _webui_endpoint_reachable(webui_url)
|
||||
if gateway_ready and webui_ready:
|
||||
console.print("[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]")
|
||||
if not dev:
|
||||
console.print(
|
||||
"Restart the gateway if you need it to pick up local source changes: "
|
||||
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||
)
|
||||
if not no_open:
|
||||
_open_webui_browser(webui_url, wait=False)
|
||||
if runtime.status().running:
|
||||
_attach_to_background_gateway(runtime)
|
||||
else:
|
||||
console.print(
|
||||
"[yellow]This gateway is controlled by another foreground command. "
|
||||
"Stop it from that terminal.[/yellow]"
|
||||
)
|
||||
return
|
||||
|
||||
lease = GatewayClientLease(runtime, kind="webui")
|
||||
lease.acquire()
|
||||
try:
|
||||
assert dev_browser_url is not None
|
||||
with run_webui_dev_server(
|
||||
target_url=webui_dev_proxy_target(webui_url),
|
||||
browser_url=dev_browser_url,
|
||||
output=lambda message: console.print(f"[green]✓[/green] {message}"),
|
||||
) as dev_server:
|
||||
if changed_webui and runtime.status().running:
|
||||
ensure_shared_gateway(client_lease=lease)
|
||||
gateway_ready = _gateway_health_ready(
|
||||
runtime_config.gateway.host,
|
||||
effective_gateway_port,
|
||||
)
|
||||
webui_ready = _webui_endpoint_reachable(webui_url)
|
||||
if not gateway_ready or not webui_ready:
|
||||
console.print("[red]Gateway did not become ready after the config update.[/red]")
|
||||
raise typer.Exit(1)
|
||||
console.print(
|
||||
"[yellow]Gateway is already running; attaching to the existing WebUI.[/yellow]"
|
||||
)
|
||||
if not dev:
|
||||
console.print(
|
||||
"Restart the gateway if you need it to pick up local source changes: "
|
||||
f"[cyan]{_gateway_instance_command('restart', config_path=config_path, workspace=workspace)}[/cyan]"
|
||||
)
|
||||
if not no_open:
|
||||
_open_webui_browser(dev_browser_url, wait=False)
|
||||
_open_webui_browser(webui_url, wait=False)
|
||||
if runtime.status().running:
|
||||
_attach_to_background_gateway(
|
||||
runtime,
|
||||
poll_hook=dev_server.ensure_running,
|
||||
)
|
||||
_attach_to_background_gateway(runtime)
|
||||
else:
|
||||
_wait_with_existing_foreground_gateway(
|
||||
runtime_config.gateway.host,
|
||||
effective_gateway_port,
|
||||
dev_server,
|
||||
console.print(
|
||||
"[yellow]This gateway is controlled by another foreground command. "
|
||||
"Stop it from that terminal.[/yellow]"
|
||||
)
|
||||
except WebUIDevError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
return
|
||||
return
|
||||
|
||||
try:
|
||||
assert dev_browser_url is not None
|
||||
with run_webui_dev_server(
|
||||
target_url=webui_dev_proxy_target(webui_url),
|
||||
browser_url=dev_browser_url,
|
||||
output=lambda message: console.print(f"[green]✓[/green] {message}"),
|
||||
) as dev_server:
|
||||
if not no_open:
|
||||
_open_webui_browser(dev_browser_url, wait=False)
|
||||
if runtime.status().running:
|
||||
_attach_to_background_gateway(
|
||||
runtime,
|
||||
poll_hook=dev_server.ensure_running,
|
||||
)
|
||||
else:
|
||||
_wait_with_existing_foreground_gateway(
|
||||
runtime_config.gateway.host,
|
||||
effective_gateway_port,
|
||||
dev_server,
|
||||
)
|
||||
except WebUIDevError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
return
|
||||
finally:
|
||||
if lease.release():
|
||||
console.print(
|
||||
"[dim]Last local client exited; the on-demand gateway was stopped.[/dim]"
|
||||
)
|
||||
|
||||
gateway_port_taken = gateway_ready or _tcp_endpoint_reachable(
|
||||
_host_for_local_browser(runtime_config.gateway.host),
|
||||
@@ -319,34 +356,33 @@ def webui(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
_print_webui_foreground_lifecycle(attached=False)
|
||||
if dev_browser_url:
|
||||
dev_proxy_target = webui_dev_proxy_target(webui_url)
|
||||
try:
|
||||
with run_webui_dev_server(
|
||||
target_url=dev_proxy_target,
|
||||
browser_url=dev_browser_url,
|
||||
output=lambda message: console.print(f"[green]✓[/green] {message}"),
|
||||
) as dev_server:
|
||||
_run_gateway(
|
||||
runtime_config,
|
||||
port=effective_gateway_port,
|
||||
open_browser_url=None if no_open else dev_browser_url,
|
||||
open_browser_ready_url=f"{dev_proxy_target}/webui/bootstrap",
|
||||
webui_static_dist=False,
|
||||
webui_bundle_mode="skip",
|
||||
unconfigured_provider_error=settings_setup_error,
|
||||
webui_dev_server=dev_server,
|
||||
)
|
||||
except WebUIDevError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
return
|
||||
lease = GatewayClientLease(runtime, kind="webui")
|
||||
lease.acquire()
|
||||
try:
|
||||
ensure_shared_gateway(client_lease=lease)
|
||||
print_shared_gateway_controls()
|
||||
if dev_browser_url:
|
||||
dev_proxy_target = webui_dev_proxy_target(webui_url)
|
||||
try:
|
||||
with run_webui_dev_server(
|
||||
target_url=dev_proxy_target,
|
||||
browser_url=dev_browser_url,
|
||||
output=lambda message: console.print(f"[green]✓[/green] {message}"),
|
||||
) as dev_server:
|
||||
if not no_open:
|
||||
_open_webui_browser(dev_browser_url)
|
||||
_attach_to_background_gateway(
|
||||
runtime,
|
||||
poll_hook=dev_server.ensure_running,
|
||||
)
|
||||
except WebUIDevError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
raise typer.Exit(1) from exc
|
||||
return
|
||||
|
||||
_run_gateway(
|
||||
runtime_config,
|
||||
port=effective_gateway_port,
|
||||
open_browser_url=None if no_open else webui_url,
|
||||
webui_bundle_mode=webui_bundle_mode,
|
||||
unconfigured_provider_error=settings_setup_error,
|
||||
)
|
||||
if not no_open:
|
||||
_open_webui_browser(webui_url)
|
||||
_attach_to_background_gateway(runtime)
|
||||
finally:
|
||||
if lease.release():
|
||||
console.print("[dim]Last local client exited; the on-demand gateway was stopped.[/dim]")
|
||||
|
||||
@@ -420,11 +420,13 @@ def _print_webui_foreground_lifecycle(*, attached: bool) -> None:
|
||||
"""Explain how the browser and gateway lifecycles differ."""
|
||||
console.print()
|
||||
if attached:
|
||||
console.print("[green]nanobot is attached to the existing gateway.[/green]")
|
||||
console.print("[green]WebUI is attached to the shared gateway.[/green]")
|
||||
else:
|
||||
console.print("[green]nanobot is running in this terminal.[/green]")
|
||||
console.print("[green]WebUI is attached to the shared gateway.[/green]")
|
||||
console.print("[dim]Closing the browser does not stop channels or automations.[/dim]")
|
||||
console.print("[dim]Press Ctrl+C here to stop nanobot.[/dim]")
|
||||
console.print(
|
||||
"[dim]Press Ctrl+C to detach; the gateway stops only when the last local client exits.[/dim]"
|
||||
)
|
||||
|
||||
|
||||
def _attach_to_background_gateway(
|
||||
@@ -432,7 +434,7 @@ def _attach_to_background_gateway(
|
||||
*,
|
||||
poll_hook: Callable[[], None] | None = None,
|
||||
) -> None:
|
||||
"""Keep a foreground WebUI command attached to a managed gateway."""
|
||||
"""Keep a WebUI launcher attached without taking ownership of the gateway."""
|
||||
_print_webui_foreground_lifecycle(attached=True)
|
||||
try:
|
||||
while runtime.status().running:
|
||||
@@ -440,13 +442,8 @@ def _attach_to_background_gateway(
|
||||
poll_hook()
|
||||
time.sleep(0.5)
|
||||
except KeyboardInterrupt:
|
||||
console.print("\n[yellow]Stopping nanobot...[/yellow]")
|
||||
result = runtime.stop()
|
||||
if result.ok or result.message == "gateway_not_running":
|
||||
console.print("[green]Gateway stopped.[/green]")
|
||||
return
|
||||
console.print(f"[red]Gateway could not be stopped: {result.message}[/red]")
|
||||
raise typer.Exit(1)
|
||||
console.print("\n[yellow]WebUI launcher detached.[/yellow]")
|
||||
return
|
||||
|
||||
console.print("[yellow]Gateway stopped.[/yellow]")
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Lightweight background runtime for the nanobot gateway."""
|
||||
|
||||
from nanobot.gateway.runtime import (
|
||||
GatewayClientLease,
|
||||
GatewayRuntime,
|
||||
GatewayRuntimePaths,
|
||||
GatewayStartOptions,
|
||||
@@ -10,6 +11,7 @@ from nanobot.gateway.runtime import (
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"GatewayClientLease",
|
||||
"GatewayRuntime",
|
||||
"GatewayRuntimePaths",
|
||||
"GatewayStartOptions",
|
||||
|
||||
+153
-1
@@ -3,12 +3,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from filelock import FileLock
|
||||
|
||||
from nanobot.config.paths import get_data_dir
|
||||
from nanobot.process_runtime import (
|
||||
@@ -97,8 +103,154 @@ class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
|
||||
return build_gateway_command(self.python_executable, options)
|
||||
|
||||
|
||||
class GatewayClientLease:
|
||||
"""Reference-count an on-demand gateway shared by local interactive clients."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
runtime: GatewayRuntime,
|
||||
*,
|
||||
kind: str,
|
||||
pid: int | None = None,
|
||||
token: str | None = None,
|
||||
) -> None:
|
||||
self.runtime = runtime
|
||||
self.kind = kind
|
||||
self.pid = pid or os.getpid()
|
||||
self.token = token or uuid.uuid4().hex
|
||||
state_path = runtime.paths.state_path
|
||||
self.state_path = state_path.with_name(
|
||||
f"{state_path.stem}.clients{state_path.suffix}"
|
||||
)
|
||||
self.lock = FileLock(f"{self.state_path}.lock")
|
||||
self._acquired = False
|
||||
|
||||
def acquire(self) -> None:
|
||||
"""Register this client before it starts or attaches to the gateway."""
|
||||
with self.lock:
|
||||
state = self._live_state()
|
||||
clients = self._clients(state)
|
||||
clients[self.token] = {
|
||||
"pid": self.pid,
|
||||
"kind": self.kind,
|
||||
}
|
||||
self._write_state(state)
|
||||
self._acquired = True
|
||||
|
||||
def mark_ephemeral(self) -> None:
|
||||
"""Mark a gateway started by a client for last-client shutdown."""
|
||||
with self.lock:
|
||||
state = self._live_state()
|
||||
state["auto_stop"] = True
|
||||
self._write_state(state)
|
||||
|
||||
def mark_persistent(self) -> None:
|
||||
"""Keep an explicitly backgrounded gateway alive without client leases."""
|
||||
with self.lock:
|
||||
state = self._live_state()
|
||||
state["auto_stop"] = False
|
||||
self._write_or_clear(state)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Forget leases after an explicit gateway stop."""
|
||||
with self.lock:
|
||||
self.state_path.unlink(missing_ok=True)
|
||||
|
||||
def release(self, *, timeout_s: int = 20) -> bool:
|
||||
"""Release this client and stop an ephemeral gateway when it was the last."""
|
||||
if not self._acquired:
|
||||
return False
|
||||
with self.lock:
|
||||
state = self._live_state()
|
||||
clients = self._clients(state)
|
||||
clients.pop(self.token, None)
|
||||
self._acquired = False
|
||||
if clients or not bool(state.get("auto_stop")):
|
||||
self._write_or_clear(state)
|
||||
return False
|
||||
result = self.runtime.stop(timeout_s=timeout_s)
|
||||
stopped = result.ok or result.message == "gateway_not_running"
|
||||
if stopped:
|
||||
self.state_path.unlink(missing_ok=True)
|
||||
else:
|
||||
self._write_state(state)
|
||||
return stopped
|
||||
|
||||
def _live_state(self) -> dict[str, object]:
|
||||
state = self._read_state()
|
||||
clients = self._clients(state)
|
||||
stale: list[str] = []
|
||||
for token, value in clients.items():
|
||||
if not isinstance(value, dict):
|
||||
stale.append(token)
|
||||
continue
|
||||
record = cast(dict[str, object], value)
|
||||
if not _pid_is_running(record.get("pid")):
|
||||
stale.append(token)
|
||||
for token in stale:
|
||||
clients.pop(token, None)
|
||||
return state
|
||||
|
||||
@staticmethod
|
||||
def _clients(state: dict[str, object]) -> dict[str, object]:
|
||||
value = state.get("clients")
|
||||
if isinstance(value, dict):
|
||||
return cast(dict[str, object], value)
|
||||
clients: dict[str, object] = {}
|
||||
state["clients"] = clients
|
||||
return clients
|
||||
|
||||
def _read_state(self) -> dict[str, object]:
|
||||
try:
|
||||
payload: object = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError, ValueError):
|
||||
return {"auto_stop": False, "clients": {}}
|
||||
if isinstance(payload, dict):
|
||||
return cast(dict[str, object], payload)
|
||||
return {"auto_stop": False, "clients": {}}
|
||||
|
||||
def _write_or_clear(self, state: dict[str, object]) -> None:
|
||||
clients = state.get("clients")
|
||||
if not clients and not bool(state.get("auto_stop")):
|
||||
self.state_path.unlink(missing_ok=True)
|
||||
return
|
||||
self._write_state(state)
|
||||
|
||||
def _write_state(self, state: dict[str, object]) -> None:
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary_name = tempfile.mkstemp(
|
||||
prefix=f"{self.state_path.name}.",
|
||||
suffix=".tmp",
|
||||
dir=self.state_path.parent,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(state, handle, indent=2, ensure_ascii=False)
|
||||
handle.write("\n")
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
temporary.replace(self.state_path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _instance_suffix(*, workspace: str | None, config_path: str | None) -> str | None:
|
||||
raw = "|".join(value for value in (workspace, config_path) if value)
|
||||
if not raw:
|
||||
return None
|
||||
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
def _pid_is_running(value: object) -> bool:
|
||||
if not isinstance(value, int) or value <= 0:
|
||||
return False
|
||||
try:
|
||||
os.kill(value, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
Reference in New Issue
Block a user