fix(cli): make gateway persistence explicit

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent 74c11e5d28
commit b8333a2d7e
10 changed files with 147 additions and 189 deletions
+16 -153
View File
@@ -2214,16 +2214,29 @@ def test_webui_yes_creates_config_and_enables_local_websocket(
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:
def test_webui_background_points_to_the_single_persistent_gateway_command(
tmp_path: Path,
) -> None:
config_file = tmp_path / "config.json"
workspace = tmp_path / "workspace"
result = runner.invoke(
app,
["webui", "--dev", "--background", "--yes", "--config", str(config_file)],
[
"webui",
"--background",
"--config",
str(config_file),
"--workspace",
str(workspace),
],
)
assert result.exit_code == 1
assert "--dev cannot be combined with --background" in result.stdout
compact_output = _strip_ansi(result.stdout).replace("\n", " ")
assert "webui --background` no longer owns gateway lifecycle" in compact_output
assert "nanobot gateway --background --config" in compact_output
assert "--workspace" in compact_output
assert not config_file.exists()
@@ -2471,77 +2484,6 @@ def test_webui_yes_still_refuses_invalid_custom_model_setup(
assert config_file.name in result.stdout
def test_webui_background_starts_runtime_and_opens_browser(monkeypatch, tmp_path: Path) -> None:
from nanobot.gateway import GatewayStartOptions, GatewayStatus, RuntimeResult
config_file = tmp_path / "config.json"
workspace = tmp_path / "workspace"
config_file.write_text("{}")
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
"nanobot.cli.webui._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
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
status = GatewayStatus(
running=True,
pid=123,
state_path=tmp_path / "gateway.json",
log_path=tmp_path / "gateway.log",
port=options.port,
reason="running",
)
return RuntimeResult(True, "gateway_started_background", status)
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.webui._open_webui_browser",
lambda url: seen.__setitem__("opened_url", url),
)
result = runner.invoke(
app,
[
"webui",
"--config",
str(config_file),
"--workspace",
str(workspace),
"--background",
"--gateway-port",
"18889",
"--yes",
],
)
assert result.exit_code == 0
assert "Gateway started in the background" in result.stdout
compact_output = _strip_ansi(result.stdout).replace("\n", " ")
assert "nanobot gateway status --config" in compact_output
assert "--workspace" in compact_output
options = seen["start_options"]
assert isinstance(options, GatewayStartOptions)
assert options.port == 18889
assert options.config_path == str(config_file.resolve(strict=False))
assert options.workspace == str(workspace.resolve(strict=False))
opened_url = seen["opened_url"]
assert isinstance(opened_url, str)
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
assert "bootstrapSecret=<redacted>" in compact_output
assert "bootstrapSecret=" in opened_url
assert "Closing the browser does not stop channels or automations" in compact_output
assert "nanobot gateway stop --config" in compact_output
def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> None:
opened: list[str] = []
url = "http://127.0.0.1:8765/#/?bootstrapSecret=super-secret"
@@ -2555,85 +2497,6 @@ def test_open_webui_browser_redacts_bootstrap_secret(monkeypatch, capsys) -> Non
assert "super-secret" not in output
def test_webui_background_restarts_when_config_changes_and_gateway_is_running(
monkeypatch,
tmp_path: Path,
) -> None:
from nanobot.gateway import GatewayStartOptions, GatewayStatus, RuntimeResult
config_file = tmp_path / "config.json"
workspace = tmp_path / "workspace"
config_file.write_text("{}")
seen: dict[str, object] = {}
_patch_webui_provider_ready(monkeypatch)
monkeypatch.setattr("nanobot.cli.webui.sync_workspace_templates", lambda _path: None)
monkeypatch.setattr(
"nanobot.cli.webui._prepare_webui_bundle_for_gateway",
lambda *_args, **_kwargs: None,
)
def _status(options: GatewayStartOptions) -> GatewayStatus:
return GatewayStatus(
running=True,
pid=123,
state_path=tmp_path / "gateway.json",
log_path=tmp_path / "gateway.log",
port=options.port,
reason="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
return RuntimeResult(False, "gateway_already_running", _status(options))
def restart(self, options: GatewayStartOptions, *, timeout_s: int) -> RuntimeResult:
seen["restart_options"] = options
seen["restart_timeout"] = timeout_s
return RuntimeResult(True, "gateway_started_background", _status(options))
monkeypatch.setattr("nanobot.gateway.GatewayRuntime", _FakeRuntime)
monkeypatch.setattr(
"nanobot.cli.webui._open_webui_browser",
lambda url: seen.__setitem__("opened_url", url),
)
result = runner.invoke(
app,
[
"webui",
"--config",
str(config_file),
"--workspace",
str(workspace),
"--background",
"--gateway-port",
"18889",
"--yes",
],
)
assert result.exit_code == 0
compact_output = _strip_ansi(result.stdout).replace("\n", " ")
assert "WebUI config changed; restarting the background gateway" in compact_output
assert "Gateway restarted in the background" in compact_output
assert "Gateway is already running" not in compact_output
options = seen["restart_options"]
assert isinstance(options, GatewayStartOptions)
assert options is seen["start_options"]
assert seen["restart_timeout"] == 20
assert options.port == 18889
assert options.config_path == str(config_file.resolve(strict=False))
assert options.workspace == str(workspace.resolve(strict=False))
opened_url = seen["opened_url"]
assert isinstance(opened_url, str)
assert opened_url.startswith("http://127.0.0.1:8765/#/?bootstrapSecret=")
def test_webui_foreground_attaches_to_existing_managed_gateway(monkeypatch, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text("{}")
+43 -1
View File
@@ -1,3 +1,4 @@
import json
from pathlib import Path
import pytest
@@ -203,6 +204,9 @@ def test_gateway_background_starts_detached_runtime(tmp_path):
def test_gateway_background_adopts_an_existing_on_demand_gateway(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 already_running(_options: GatewayStartOptions) -> RuntimeResult:
return RuntimeResult(False, "gateway_already_running", fake_runtime.status_value)
@@ -212,7 +216,23 @@ def test_gateway_background_adopts_an_existing_on_demand_gateway(tmp_path):
result = runner.invoke(app, ["gateway", "--background"])
assert result.exit_code == 0
assert "Gateway is already running in the background" in result.stdout
assert "promoted to persistent background mode" in result.stdout
assert "will keep running after all local clients exit" in result.stdout
assert not lease_state.exists()
def test_gateway_background_reports_an_existing_persistent_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 "already running in persistent background mode" in result.stdout
def test_gateway_rejects_conflicting_modes(tmp_path):
@@ -267,6 +287,9 @@ def test_gateway_restart_starts_background_runtime(tmp_path):
config = Config()
config.gateway.port = 18793
app, fake_runtime, _service, _calls, prepare_calls = _test_app(tmp_path, config=config)
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")
result = runner.invoke(app, ["gateway", "restart", "--timeout", "9", "--verbose"])
@@ -275,6 +298,25 @@ def test_gateway_restart_starts_background_runtime(tmp_path):
assert fake_runtime.stop_timeout == 9
assert fake_runtime.restarted_options == GatewayStartOptions(port=18793, verbose=True)
assert prepare_calls == [(config, "warn")]
assert json.loads(lease_state.read_text(encoding="utf-8"))["auto_stop"] is True
def test_gateway_restart_does_not_create_a_persistent_gateway(tmp_path):
app, fake_runtime, _service, _calls, _prepare_calls = _test_app(tmp_path)
def not_running(
_options: GatewayStartOptions, *, timeout_s: int
) -> RuntimeResult:
fake_runtime.stop_timeout = timeout_s
return RuntimeResult(False, "gateway_not_running", fake_runtime.status_value)
fake_runtime.restart = not_running # type: ignore[method-assign]
result = runner.invoke(app, ["gateway", "restart"])
assert result.exit_code == 1
assert "there is nothing to restart" in result.stdout
assert "nanobot gateway --background" in result.stdout
def test_gateway_install_service_uses_service_installer(tmp_path):
+16 -1
View File
@@ -187,6 +187,21 @@ def test_concurrent_background_starts_create_only_one_process(tmp_path, monkeypa
]
def test_restart_does_not_start_a_gateway_that_is_not_running(tmp_path):
spawned: list[list[str]] = []
runtime = GatewayRuntime(
paths=_paths(tmp_path),
platform_name="Linux",
popen=lambda command, **_kwargs: spawned.append(command),
)
result = runtime.restart(GatewayStartOptions(port=18790))
assert result.ok is False
assert result.message == "gateway_not_running"
assert spawned == []
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] = []
@@ -222,7 +237,7 @@ def test_explicit_background_gateway_survives_the_last_client(tmp_path, monkeypa
client.acquire()
client.mark_ephemeral()
GatewayClientLease(runtime, kind="gateway-background").mark_persistent()
assert GatewayClientLease(runtime, kind="gateway-background").mark_persistent() is True
assert client.release() is False
assert stopped == []