mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(cli): share on-demand gateway lifecycle
This commit is contained in:
@@ -161,7 +161,7 @@ If `nanobot` is not on `PATH`, invoke it through the method that installed it: r
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
This is the recommended first run. The launcher creates the config and workspace when needed, safely enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). A fresh install can open before a model is configured, so setup continues in the browser instead of beginning in a JSON file. The first-run WebUI binds to localhost by default and is not exposed to your LAN.
|
||||
This is the recommended first run. The launcher creates the config and workspace when needed, safely enables the local WebSocket channel after confirmation, starts or joins the shared local gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). A fresh install can open before a model is configured, so setup continues in the browser instead of beginning in a JSON file. The first-run WebUI binds to localhost by default and is not exposed to your LAN.
|
||||
|
||||
**Your first three steps**
|
||||
|
||||
@@ -202,7 +202,7 @@ Use `nanobot gateway --background` for the same direct entry point without keepi
|
||||
nanobot agent
|
||||
```
|
||||
|
||||
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` steers the current turn, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message for editing. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback for terminals that cannot distinguish modified Enter keys. Use `PageUp` at the top to load earlier transcript pages. The next launch returns to your last session unless `--session` selects another WebSocket session; use `--classic` to resume a session from another channel. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. The client starts the shared local gateway when needed and leaves it available to other terminals and the WebUI; stop it explicitly with `nanobot gateway stop`. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
|
||||
This opens the native terminal client with the same configured model, workspace, tools, streaming protocol, and session engine as the WebUI. Use `/sessions` to switch saved conversations, `/new-chat` to preserve this conversation and start another one, `/branch` to fork from a completed reply, `/context` to inspect the compacted summary and raw message suffix available to the agent, or `/diff` to review the latest turn's file changes. Type `@` to mention an installed app, configured MCP server, or saved session. While nanobot is working, `Enter` steers the current turn, `Tab` queues a visible follow-up for the next turn, and `Option+Up` on macOS (`Alt+Up` on Windows/Linux) returns the latest queued message for editing. Press `Shift+Enter` to add a newline; `Ctrl+J` is the universal fallback for terminals that cannot distinguish modified Enter keys. Use `PageUp` at the top to load earlier transcript pages. The next launch returns to your last session unless `--session` selects another WebSocket session; use `--classic` to resume a session from another channel. The existing nanobot `/new` command keeps its original behavior: it resets the current chat. `nanobot agent` and `nanobot webui` share one on-demand local gateway: either command can start it, each launcher releases only its own client, and the last interactive launcher to exit stops it. Use `nanobot gateway --background` when the gateway must stay alive with no local clients. Type `exit` or press `Ctrl+C` when you are done. Use `nanobot agent --classic` for the legacy Python prompt.
|
||||
|
||||
For one request and an immediate exit, use:
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ nanobot sessions restore-workspace --config ./bot-a/config.json --workspace ./bo
|
||||
The command never deletes the external store and refuses to overwrite a different existing
|
||||
workspace file. Back up both the config directory and workspace before changing versions.
|
||||
|
||||
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, the command starts the shared background gateway. Exiting one TUI disconnects only that client, so other terminals and the WebUI stay connected; use `nanobot gateway stop` when you want to stop the shared process.
|
||||
Interactive mode uses nanobot's native TypeScript terminal UI. It talks to the same local gateway as the WebUI, so streaming, tool progress, and WebSocket sessions share one protocol instead of maintaining a second agent loop. If no gateway is running, either client starts it on demand. Exiting one TUI or WebUI launcher releases only that client; the last interactive launcher stops the on-demand gateway. `nanobot gateway --background`, `nanobot gateway restart`, and `nanobot webui --background` make it persistent until an explicit `nanobot gateway stop`.
|
||||
|
||||
The default `--theme auto` mode probes the terminal's real foreground and background colors before first paint and follows supported live appearance changes. Use `--theme light` or `--theme dark` when a terminal or multiplexer does not report its colors reliably. The model preset and workspace access labels above the composer can be clicked to open their selectors; arrow keys, `Enter`, and `Esc` provide the same controls without a mouse. Access changes still pass through the gateway's local-trust and active-turn policy checks.
|
||||
|
||||
@@ -149,7 +149,8 @@ First-run WebUI setup binds to `127.0.0.1` by default. Use manual configuration
|
||||
|
||||
`--dev` is a foreground source-checkout workflow and cannot be combined with `--background`.
|
||||
It installs frontend dependencies when `webui/node_modules` is missing, proxies to the configured
|
||||
WebSocket channel port, and stops Vite together with the foreground gateway.
|
||||
WebSocket channel port, and stops Vite when the launcher exits. The shared on-demand gateway stops
|
||||
only when no other interactive client still holds it.
|
||||
|
||||
## Gateway
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ nanobot webui -c ~/.nanobot-telegram/config.json
|
||||
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
|
||||
```
|
||||
|
||||
> `nanobot agent` starts a local CLI agent using the selected workspace/config. It does not attach to or proxy through an already running `nanobot gateway` process.
|
||||
> Interactive `nanobot agent` and `nanobot webui` commands with the same `--config` and explicit `--workspace` selectors share one gateway instance. Different selectors produce isolated runtime state and processes. The one-shot and `--classic` agent paths remain direct local executions.
|
||||
|
||||
| Component | Resolved From | Example |
|
||||
|-----------|---------------|---------|
|
||||
|
||||
+2
-2
@@ -79,7 +79,7 @@ Most other providers can say `not set`. This command validates local setup but d
|
||||
|
||||
## 4. Get the First Reply
|
||||
|
||||
If the installer-started WebUI is no longer running, run `nanobot webui` again. Leave that terminal open; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it.
|
||||
If the installer-started WebUI is no longer running, run `nanobot webui` again. Leave that launcher open; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it.
|
||||
|
||||
Send:
|
||||
|
||||
@@ -89,7 +89,7 @@ Hello!
|
||||
|
||||
Any normal assistant answer is success. It proves that nanobot can load the config, reach the selected model, use the workspace, and serve the browser UI.
|
||||
|
||||
Leave the terminal open while using the WebUI. If you prefer a managed background process, stop the foreground process with `Ctrl+C`, then run:
|
||||
Interactive WebUI and TUI launchers share one on-demand gateway. Closing one launcher leaves it running for the others; closing the last launcher stops it. If you prefer a persistent background process, press `Ctrl+C`, then run:
|
||||
|
||||
```bash
|
||||
nanobot gateway --background
|
||||
|
||||
@@ -160,4 +160,4 @@ Run:
|
||||
nanobot webui
|
||||
```
|
||||
|
||||
Leave that terminal open while you use nanobot. To stop it, return to the terminal and press `Ctrl+C`. Use `nanobot webui --background` only after the normal foreground start and model setup work; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||
Leave that launcher open while you use nanobot. Pressing `Ctrl+C` disconnects it; the shared gateway stops when it was the last local WebUI or TUI client. Use `nanobot webui --background` only after the normal foreground start and model setup work when you want the gateway to stay online with no clients; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||
|
||||
+5
-2
@@ -19,7 +19,8 @@ nanobot webui
|
||||
|
||||
`nanobot webui` creates the config/workspace when needed, enables the local
|
||||
WebSocket channel after confirmation, generates a WebUI bootstrap secret when
|
||||
one is missing, starts the gateway, and opens the browser. With a fresh config,
|
||||
one is missing, starts or joins the same on-demand gateway used by the native
|
||||
TUI, and opens the browser. With a fresh config,
|
||||
it can open before a model is configured so you can finish setup in **Settings
|
||||
→ Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so
|
||||
it is not available from other devices on your LAN.
|
||||
@@ -33,7 +34,9 @@ nanobot webui --background
|
||||
Complete first-time model setup in a foreground `nanobot webui` session before using
|
||||
`--background`.
|
||||
|
||||
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
|
||||
Each foreground WebUI or TUI launcher releases only its own client. The last
|
||||
interactive launcher stops an on-demand gateway. `--background` makes the
|
||||
gateway persistent; manage it with `nanobot gateway status`, `nanobot gateway
|
||||
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
|
||||
|
||||
Manual config still works. Same-machine localhost WebUI access can run without
|
||||
|
||||
+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
|
||||
|
||||
+99
-56
@@ -207,7 +207,7 @@ def test_webui_restores_tty_before_loading_config(monkeypatch, tmp_path: Path) -
|
||||
monkeypatch.setattr(cli_webui, "_gateway_health_ready", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(cli_webui, "_webui_endpoint_reachable", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(cli_webui, "_tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(cli_webui, "_run_gateway", lambda *_args, **_kwargs: None)
|
||||
_patch_webui_managed_gateway(monkeypatch)
|
||||
|
||||
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
|
||||
|
||||
@@ -1949,6 +1949,54 @@ def _patch_gateway_ports_free(monkeypatch) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _patch_webui_managed_gateway(
|
||||
monkeypatch,
|
||||
seen: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""Run WebUI CLI tests against a managed gateway without spawning a process."""
|
||||
from nanobot.gateway import GatewayStatus, RuntimeResult
|
||||
|
||||
captured = seen if seen is not None else {}
|
||||
|
||||
class _FakeRuntime:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
captured["runtime_kwargs"] = kwargs
|
||||
self.paths = kwargs["paths"]
|
||||
self.running = False
|
||||
|
||||
def start_background(self, options):
|
||||
captured["start_options"] = options
|
||||
self.running = True
|
||||
status = GatewayStatus(
|
||||
running=True,
|
||||
pid=123,
|
||||
state_path=Path("gateway.json"),
|
||||
log_path=Path("gateway.log"),
|
||||
port=options.port,
|
||||
reason="running",
|
||||
)
|
||||
return RuntimeResult(True, "gateway_started_background", status)
|
||||
|
||||
def status(self):
|
||||
return SimpleNamespace(running=self.running)
|
||||
|
||||
def stop(self, *, timeout_s: int):
|
||||
captured["stop_timeout"] = timeout_s
|
||||
self.running = False
|
||||
return RuntimeResult(True, "gateway_stopped", self.status())
|
||||
|
||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.webui._prepare_webui_bundle_for_gateway",
|
||||
lambda *_args, **_kwargs: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.webui._attach_to_background_gateway",
|
||||
lambda runtime, **_kwargs: captured.__setitem__("attached_runtime", runtime),
|
||||
)
|
||||
return captured
|
||||
|
||||
|
||||
def _patch_cli_command_runtime(
|
||||
monkeypatch,
|
||||
config: Config,
|
||||
@@ -2125,11 +2173,7 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
|
||||
lambda path: seen.__setitem__("templates", path),
|
||||
)
|
||||
|
||||
def _fake_run_gateway(config: Config, **kwargs) -> None:
|
||||
seen["gateway_config"] = config
|
||||
seen["gateway_kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.webui._run_gateway", _fake_run_gateway)
|
||||
_patch_webui_managed_gateway(monkeypatch, seen)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -2159,18 +2203,15 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
|
||||
assert len(websocket["tokenIssueSecret"]) >= 32
|
||||
assert data["agents"]["defaults"]["workspace"] == str(workspace)
|
||||
assert seen["templates"] == workspace
|
||||
assert seen["gateway_kwargs"] == {
|
||||
"port": 18888,
|
||||
"open_browser_url": None,
|
||||
"webui_bundle_mode": "auto",
|
||||
"unconfigured_provider_error": None,
|
||||
}
|
||||
options = seen["start_options"]
|
||||
assert options.port == 18888
|
||||
assert options.config_path == str(config_file.resolve(strict=False))
|
||||
assert options.workspace == str(workspace.resolve(strict=False))
|
||||
compact_output = re.sub(r"\s+", " ", _strip_ansi(result.stdout))
|
||||
assert "bootstrap secret was generated" in compact_output
|
||||
assert "channels.websocket.tokenIssueSecret" in compact_output
|
||||
assert "rerun without --no-open" in compact_output
|
||||
assert "nanobot is running in this terminal" in compact_output
|
||||
assert "Press Ctrl+C here to stop nanobot" in compact_output
|
||||
assert "Last local client exited; the on-demand gateway was stopped" in compact_output
|
||||
|
||||
|
||||
def test_webui_dev_rejects_background_before_creating_config(tmp_path: Path) -> None:
|
||||
@@ -2208,12 +2249,20 @@ def test_webui_dev_starts_vite_sidecar_and_gateway(monkeypatch, tmp_path: Path)
|
||||
finally:
|
||||
seen["dev_running"] = False
|
||||
|
||||
def fake_run_gateway(_config: Config, **kwargs) -> None:
|
||||
assert seen["dev_running"] is True
|
||||
seen["gateway_kwargs"] = kwargs
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.webui.run_webui_dev_server", fake_dev_server)
|
||||
monkeypatch.setattr("nanobot.cli.webui._run_gateway", fake_run_gateway)
|
||||
_patch_webui_managed_gateway(monkeypatch, seen)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.webui._attach_to_background_gateway",
|
||||
lambda runtime, **kwargs: seen.update(
|
||||
attached_runtime=runtime,
|
||||
attach_kwargs=kwargs,
|
||||
attached_while_dev_running=seen["dev_running"],
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.webui._open_webui_browser",
|
||||
lambda url: seen.__setitem__("opened_url", url),
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -2237,17 +2286,10 @@ def test_webui_dev_starts_vite_sidecar_and_gateway(monkeypatch, tmp_path: Path)
|
||||
browser_url = dev_kwargs["browser_url"]
|
||||
assert isinstance(browser_url, str)
|
||||
assert browser_url.startswith("http://127.0.0.1:5173/#/?bootstrapSecret=")
|
||||
gateway_kwargs = seen["gateway_kwargs"]
|
||||
assert isinstance(gateway_kwargs, dict)
|
||||
assert gateway_kwargs == {
|
||||
"port": 18888,
|
||||
"open_browser_url": browser_url,
|
||||
"open_browser_ready_url": "http://127.0.0.1:8899/webui/bootstrap",
|
||||
"webui_static_dist": False,
|
||||
"webui_bundle_mode": "skip",
|
||||
"unconfigured_provider_error": None,
|
||||
"webui_dev_server": seen["dev_server"],
|
||||
}
|
||||
assert seen["start_options"].port == 18888
|
||||
assert seen["attached_while_dev_running"] is True
|
||||
assert seen["attach_kwargs"] == {"poll_hook": seen["dev_server"].ensure_running}
|
||||
assert seen["opened_url"] == browser_url
|
||||
assert seen["dev_running"] is False
|
||||
assert "WebUI dev: http://127.0.0.1:5173/#/?bootstrapSecret=<redacted>" in re.sub(
|
||||
r"\s+", " ", _strip_ansi(result.stdout)
|
||||
@@ -2352,16 +2394,13 @@ def test_webui_yes_starts_first_run_without_provider_setup(monkeypatch, tmp_path
|
||||
)
|
||||
_patch_gateway_ports_free(monkeypatch)
|
||||
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.webui._run_gateway",
|
||||
lambda config, **kwargs: seen.update(config=config, **kwargs),
|
||||
)
|
||||
_patch_webui_managed_gateway(monkeypatch, seen)
|
||||
|
||||
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert config_file.exists()
|
||||
assert seen["unconfigured_provider_error"] == "No API key configured for provider 'custom'."
|
||||
assert seen["start_options"].config_path == str(config_file.resolve(strict=False))
|
||||
assert "Configure a provider and model in WebUI Settings → Models." in result.stdout
|
||||
|
||||
|
||||
@@ -2390,11 +2429,6 @@ def test_webui_missing_runtime_env_fails_before_starting_gateway(
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.webui._run_gateway",
|
||||
lambda *_args, **_kwargs: pytest.fail("gateway must not start with unresolved config"),
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes", "--no-open"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
@@ -2454,6 +2488,7 @@ def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path
|
||||
class _FakeRuntime:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
seen["runtime_kwargs"] = kwargs
|
||||
self.paths = kwargs["paths"]
|
||||
|
||||
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
|
||||
seen["start_options"] = options
|
||||
@@ -2550,6 +2585,7 @@ def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
|
||||
class _FakeRuntime:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
seen["runtime_kwargs"] = kwargs
|
||||
self.paths = kwargs["paths"]
|
||||
|
||||
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
|
||||
seen["start_options"] = options
|
||||
@@ -2610,18 +2646,30 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
|
||||
"nanobot.cli.webui._open_webui_browser",
|
||||
lambda url, **kwargs: seen.update({"opened_url": url, "open_kwargs": kwargs}),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.webui._run_gateway",
|
||||
lambda *_args, **_kwargs: pytest.fail("existing gateway should be reused"),
|
||||
)
|
||||
|
||||
class _FakeRuntime:
|
||||
def __init__(self, **kwargs) -> None:
|
||||
seen["runtime_kwargs"] = kwargs
|
||||
self.paths = kwargs["paths"]
|
||||
|
||||
def status(self):
|
||||
return SimpleNamespace(running=True)
|
||||
|
||||
def start_background(self, options):
|
||||
return SimpleNamespace(
|
||||
ok=False,
|
||||
message="gateway_already_running",
|
||||
status=SimpleNamespace(log_path=self.paths.log_path),
|
||||
)
|
||||
|
||||
def restart(self, options, *, timeout_s: int):
|
||||
seen["restart_options"] = options
|
||||
seen["restart_timeout"] = timeout_s
|
||||
return SimpleNamespace(
|
||||
ok=True,
|
||||
message="gateway_started_background",
|
||||
status=SimpleNamespace(log_path=self.paths.log_path),
|
||||
)
|
||||
|
||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.webui._attach_to_background_gateway",
|
||||
@@ -2642,7 +2690,7 @@ def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_
|
||||
assert seen["open_kwargs"] == {"wait": False}
|
||||
|
||||
|
||||
def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> None:
|
||||
def test_attach_to_background_gateway_detaches_on_ctrl_c(monkeypatch, capsys) -> None:
|
||||
stopped = False
|
||||
|
||||
class _FakeRuntime:
|
||||
@@ -2661,11 +2709,11 @@ def test_attach_to_background_gateway_stops_on_ctrl_c(monkeypatch, capsys) -> No
|
||||
|
||||
cli_webui_support._attach_to_background_gateway(_FakeRuntime())
|
||||
|
||||
assert stopped is True
|
||||
assert stopped is False
|
||||
output = capsys.readouterr().out
|
||||
assert "Closing the browser does not stop channels or automations" in output
|
||||
assert "Press Ctrl+C here to stop nanobot" in output
|
||||
assert "Gateway stopped" in output
|
||||
assert "gateway stops only when the last local client exits" in output
|
||||
assert "WebUI launcher detached" in output
|
||||
|
||||
|
||||
def test_attach_to_background_gateway_checks_owned_sidecar() -> None:
|
||||
@@ -2697,8 +2745,8 @@ def test_webui_foreground_does_not_claim_unmanaged_gateway(monkeypatch, tmp_path
|
||||
)
|
||||
|
||||
class _FakeRuntime:
|
||||
def __init__(self, **_kwargs) -> None:
|
||||
pass
|
||||
def __init__(self, **kwargs) -> None:
|
||||
self.paths = kwargs["paths"]
|
||||
|
||||
def status(self):
|
||||
return SimpleNamespace(running=False)
|
||||
@@ -2719,11 +2767,6 @@ def test_webui_foreground_refuses_occupied_webui_port(monkeypatch, tmp_path: Pat
|
||||
monkeypatch.setattr("nanobot.cli.webui._gateway_health_ready", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr("nanobot.cli.webui._webui_endpoint_reachable", lambda *_args, **_kwargs: True)
|
||||
monkeypatch.setattr("nanobot.cli.webui._tcp_endpoint_reachable", lambda *_args, **_kwargs: False)
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.webui._run_gateway",
|
||||
lambda *_args, **_kwargs: pytest.fail("gateway should not start on occupied ports"),
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["webui", "--config", str(config_file), "--yes"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
|
||||
@@ -7,7 +7,7 @@ from typer.testing import CliRunner
|
||||
|
||||
from nanobot.cli.gateway import create_gateway_app
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.gateway import GatewayStartOptions, GatewayStatus, RuntimeResult
|
||||
from nanobot.gateway import GatewayRuntimePaths, GatewayStartOptions, GatewayStatus, RuntimeResult
|
||||
from nanobot.gateway.service import GatewayServiceOptions, GatewayServiceResult
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -15,6 +15,7 @@ runner = CliRunner()
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, tmp_path: Path):
|
||||
self.paths = GatewayRuntimePaths.for_instance(data_dir=tmp_path)
|
||||
self.status_value = GatewayStatus(
|
||||
running=True,
|
||||
pid=12345,
|
||||
@@ -200,6 +201,20 @@ def test_gateway_background_starts_detached_runtime(tmp_path):
|
||||
assert prepare_calls == [(config, "warn")]
|
||||
|
||||
|
||||
def test_gateway_background_adopts_an_existing_on_demand_gateway(tmp_path):
|
||||
app, fake_runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
|
||||
|
||||
def already_running(_options: GatewayStartOptions) -> RuntimeResult:
|
||||
return RuntimeResult(False, "gateway_already_running", fake_runtime.status_value)
|
||||
|
||||
fake_runtime.start_background = already_running # type: ignore[method-assign]
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--background"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Gateway is already running in the background" in result.stdout
|
||||
|
||||
|
||||
def test_gateway_rejects_conflicting_modes(tmp_path):
|
||||
app, _runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
|
||||
|
||||
@@ -230,6 +245,9 @@ def test_gateway_logs_can_read_without_following(tmp_path):
|
||||
|
||||
def test_gateway_stop_treats_not_running_as_clean(tmp_path):
|
||||
app, fake_runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
|
||||
lease_state = fake_runtime.paths.state_path.with_name("gateway.clients.json")
|
||||
lease_state.parent.mkdir(parents=True, exist_ok=True)
|
||||
lease_state.write_text('{"auto_stop": true, "clients": {}}', encoding="utf-8")
|
||||
|
||||
def fake_stop(*, timeout_s: int) -> RuntimeResult:
|
||||
fake_runtime.stop_timeout = timeout_s
|
||||
@@ -242,6 +260,7 @@ def test_gateway_stop_treats_not_running_as_clean(tmp_path):
|
||||
assert result.exit_code == 0
|
||||
assert "gateway_not_running" in result.stdout
|
||||
assert fake_runtime.stop_timeout == 3
|
||||
assert not lease_state.exists()
|
||||
|
||||
|
||||
def test_gateway_restart_starts_background_runtime(tmp_path):
|
||||
|
||||
@@ -79,12 +79,18 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
|
||||
config.model_presets["Deep Research"] = ModelPresetConfig(model="openai/gpt-5.6")
|
||||
config.agents.defaults.model_preset = "Deep Research"
|
||||
captured: dict[str, str] = {}
|
||||
released: list[bool] = []
|
||||
|
||||
class FakeLease:
|
||||
def release(self) -> None:
|
||||
released.append(True)
|
||||
|
||||
monkeypatch.setattr("nanobot.cli.tui_launcher._resolve_tui_command", lambda: ["nanobot-tui"])
|
||||
monkeypatch.setattr(
|
||||
"nanobot.cli.tui_launcher._ensure_gateway",
|
||||
lambda *args, **kwargs: SimpleNamespace(
|
||||
base_url="http://127.0.0.1:8765",
|
||||
lease=FakeLease(),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
@@ -116,6 +122,7 @@ def test_launcher_passes_the_canonical_model_preset_to_the_tui(
|
||||
assert captured["NANOBOT_TUI_MODEL"] == "openai/gpt-5.6"
|
||||
assert captured["NANOBOT_TUI_MODEL_PRESET"] == "Deep Research"
|
||||
assert "NANOBOT_TUI_CHAT_ID" not in captured
|
||||
assert released == [True]
|
||||
|
||||
|
||||
def test_explicit_tui_binary_must_exist(
|
||||
@@ -451,12 +458,13 @@ def test_gateway_reuses_the_matching_managed_instance(
|
||||
assert gateway.base_url == "http://127.0.0.1:8765"
|
||||
|
||||
|
||||
def test_gateway_started_for_tui_remains_shared_after_launch(
|
||||
def test_gateway_started_for_tui_stops_when_its_last_lease_exits(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
config = Config()
|
||||
started = False
|
||||
stopped = False
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, *, paths: object) -> None:
|
||||
@@ -477,8 +485,11 @@ def test_gateway_started_for_tui_remains_shared_after_launch(
|
||||
status=SimpleNamespace(log_path=tmp_path / "gateway.log"),
|
||||
)
|
||||
|
||||
def stop(self, *, timeout_s: int) -> None:
|
||||
raise AssertionError(f"shared gateway stopped with timeout {timeout_s}")
|
||||
def stop(self, *, timeout_s: int) -> SimpleNamespace:
|
||||
nonlocal stopped
|
||||
assert timeout_s == 20
|
||||
stopped = True
|
||||
return SimpleNamespace(ok=True, message="gateway_stopped")
|
||||
|
||||
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", FakeRuntime)
|
||||
monkeypatch.setattr(
|
||||
@@ -494,3 +505,6 @@ def test_gateway_started_for_tui_remains_shared_after_launch(
|
||||
|
||||
assert gateway.base_url == "http://127.0.0.1:8765"
|
||||
assert started is True
|
||||
assert gateway.lease is not None
|
||||
assert gateway.lease.release() is True
|
||||
assert stopped is True
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions, GatewayStatus
|
||||
from nanobot.gateway import (
|
||||
GatewayClientLease,
|
||||
GatewayRuntime,
|
||||
GatewayRuntimePaths,
|
||||
GatewayStartOptions,
|
||||
GatewayStatus,
|
||||
)
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
@@ -179,6 +187,64 @@ def test_concurrent_background_starts_create_only_one_process(tmp_path, monkeypa
|
||||
]
|
||||
|
||||
|
||||
def test_last_interactive_client_stops_an_on_demand_gateway(tmp_path, monkeypatch):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
stopped: list[int] = []
|
||||
|
||||
def stop(*, timeout_s: int):
|
||||
stopped.append(timeout_s)
|
||||
return SimpleNamespace(ok=True, message="gateway_stopped")
|
||||
|
||||
monkeypatch.setattr(runtime, "stop", stop)
|
||||
tui = GatewayClientLease(runtime, kind="tui", pid=os.getpid(), token="tui")
|
||||
webui = GatewayClientLease(runtime, kind="webui", pid=os.getpid(), token="webui")
|
||||
|
||||
tui.acquire()
|
||||
tui.mark_ephemeral()
|
||||
webui.acquire()
|
||||
|
||||
assert tui.release() is False
|
||||
assert stopped == []
|
||||
assert webui.release() is True
|
||||
assert stopped == [20]
|
||||
assert not webui.state_path.exists()
|
||||
|
||||
|
||||
def test_explicit_background_gateway_survives_the_last_client(tmp_path, monkeypatch):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
stopped: list[int] = []
|
||||
monkeypatch.setattr(
|
||||
runtime,
|
||||
"stop",
|
||||
lambda *, timeout_s: stopped.append(timeout_s),
|
||||
)
|
||||
client = GatewayClientLease(runtime, kind="webui", pid=os.getpid())
|
||||
|
||||
client.acquire()
|
||||
client.mark_ephemeral()
|
||||
GatewayClientLease(runtime, kind="gateway-background").mark_persistent()
|
||||
|
||||
assert client.release() is False
|
||||
assert stopped == []
|
||||
assert not client.state_path.exists()
|
||||
|
||||
|
||||
def test_failed_last_client_shutdown_remains_retryable(tmp_path, monkeypatch):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
monkeypatch.setattr(
|
||||
runtime,
|
||||
"stop",
|
||||
lambda *, timeout_s: SimpleNamespace(ok=False, message="gateway_stop_timeout"),
|
||||
)
|
||||
client = GatewayClientLease(runtime, kind="tui", pid=os.getpid())
|
||||
client.acquire()
|
||||
client.mark_ephemeral()
|
||||
|
||||
assert client.release() is False
|
||||
state = json.loads(client.state_path.read_text(encoding="utf-8"))
|
||||
assert state == {"auto_stop": True, "clients": {}}
|
||||
|
||||
|
||||
def test_start_background_uses_windows_process_group_flags(tmp_path, monkeypatch):
|
||||
calls: list[dict] = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user