feat(gateway): add background and service controls
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
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.service import GatewayServiceOptions, GatewayServiceResult
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, tmp_path: Path):
|
||||
self.status_value = GatewayStatus(
|
||||
running=True,
|
||||
pid=12345,
|
||||
state_path=tmp_path / "gateway.json",
|
||||
log_path=tmp_path / "gateway.log",
|
||||
started_at="2026-06-22T00:00:00Z",
|
||||
port=18790,
|
||||
reason="running",
|
||||
)
|
||||
self.started_options: GatewayStartOptions | None = None
|
||||
self.restarted_options: GatewayStartOptions | None = None
|
||||
self.stop_timeout: int | None = None
|
||||
self.follow_tail: int | None = None
|
||||
|
||||
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
|
||||
self.started_options = options
|
||||
return RuntimeResult(True, "gateway_started_background", self.status_value)
|
||||
|
||||
def restart(self, options: GatewayStartOptions, *, timeout_s: int) -> RuntimeResult:
|
||||
self.restarted_options = options
|
||||
self.stop_timeout = timeout_s
|
||||
return RuntimeResult(True, "gateway_started_background", self.status_value)
|
||||
|
||||
def stop(self, *, timeout_s: int) -> RuntimeResult:
|
||||
self.stop_timeout = timeout_s
|
||||
return RuntimeResult(True, "gateway_stopped", self.status_value)
|
||||
|
||||
def status(self) -> GatewayStatus:
|
||||
return self.status_value
|
||||
|
||||
def read_log_tail(self, *, tail: int) -> list[str]:
|
||||
return [f"line {tail}"]
|
||||
|
||||
def follow_logs(self, *, tail: int) -> int:
|
||||
self.follow_tail = tail
|
||||
return 0
|
||||
|
||||
|
||||
class FakeServiceInstaller:
|
||||
def __init__(self, tmp_path: Path):
|
||||
self.tmp_path = tmp_path
|
||||
self.installed_options: GatewayServiceOptions | None = None
|
||||
self.install_dry_run: bool | None = None
|
||||
self.uninstalled_name: str | None = None
|
||||
self.uninstall_manager: str | None = None
|
||||
|
||||
def install(self, options: GatewayServiceOptions, *, dry_run: bool) -> GatewayServiceResult:
|
||||
self.installed_options = options
|
||||
self.install_dry_run = dry_run
|
||||
return GatewayServiceResult(
|
||||
True,
|
||||
"service_install_dry_run" if dry_run else "service_installed",
|
||||
"systemd",
|
||||
self.tmp_path / "nanobot-gateway.service",
|
||||
(("systemctl", "--user", "daemon-reload"),),
|
||||
"[Unit]\nDescription=Nanobot Gateway\n",
|
||||
)
|
||||
|
||||
def uninstall(self, *, name: str, manager: str, dry_run: bool) -> GatewayServiceResult:
|
||||
self.uninstalled_name = name
|
||||
self.uninstall_manager = manager
|
||||
return GatewayServiceResult(
|
||||
True,
|
||||
"service_uninstall_dry_run" if dry_run else "service_uninstalled",
|
||||
"systemd",
|
||||
self.tmp_path / "nanobot-gateway.service",
|
||||
(("systemctl", "--user", "disable", "--now", "nanobot-gateway.service"),),
|
||||
)
|
||||
|
||||
|
||||
def _test_app(tmp_path: Path, config: Config | None = None):
|
||||
app = typer.Typer()
|
||||
fake_runtime = FakeRuntime(tmp_path)
|
||||
fake_service = FakeServiceInstaller(tmp_path)
|
||||
run_calls: list[tuple[Config, int | None]] = []
|
||||
|
||||
def load_runtime_config(_config_path: str | None, _workspace: str | None) -> Config:
|
||||
return config or Config()
|
||||
|
||||
def run_gateway(config: Config, *, port: int | None = None) -> None:
|
||||
run_calls.append((config, port))
|
||||
|
||||
app.add_typer(
|
||||
create_gateway_app(
|
||||
console=Console(),
|
||||
log_handler_id=0,
|
||||
load_runtime_config=load_runtime_config,
|
||||
run_gateway=run_gateway,
|
||||
runtime_factory=lambda **_kwargs: fake_runtime,
|
||||
service_factory=lambda: fake_service,
|
||||
),
|
||||
name="gateway",
|
||||
)
|
||||
return app, fake_runtime, fake_service, run_calls
|
||||
|
||||
|
||||
def test_gateway_default_still_runs_foreground(tmp_path):
|
||||
app, _runtime, _service, calls = _test_app(tmp_path)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--port", "18791"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1] == 18791
|
||||
|
||||
|
||||
def test_gateway_background_starts_detached_runtime(tmp_path):
|
||||
config = Config()
|
||||
config.gateway.port = 18792
|
||||
app, fake_runtime, _service, _calls = _test_app(tmp_path, config=config)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--background"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Gateway started in the background" in result.stdout
|
||||
assert fake_runtime.started_options == GatewayStartOptions(port=18792)
|
||||
|
||||
|
||||
def test_gateway_rejects_conflicting_modes(tmp_path):
|
||||
app, _runtime, _service, _calls = _test_app(tmp_path)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "--foreground", "--background"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "--foreground and --background cannot be used together" in result.stdout
|
||||
|
||||
|
||||
def test_gateway_status_uses_runtime(tmp_path):
|
||||
app, _runtime, _service, _calls = _test_app(tmp_path)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "status"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Running: yes" in result.stdout
|
||||
assert "PID: 12345" in result.stdout
|
||||
|
||||
|
||||
def test_gateway_logs_can_read_without_following(tmp_path):
|
||||
app, _runtime, _service, _calls = _test_app(tmp_path)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "logs", "--tail", "12", "--no-follow"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "line 12" in result.stdout
|
||||
|
||||
|
||||
def test_gateway_stop_treats_not_running_as_clean(tmp_path):
|
||||
app, fake_runtime, _service, _calls = _test_app(tmp_path)
|
||||
|
||||
def fake_stop(*, timeout_s: int) -> RuntimeResult:
|
||||
fake_runtime.stop_timeout = timeout_s
|
||||
return RuntimeResult(False, "gateway_not_running", fake_runtime.status_value)
|
||||
|
||||
fake_runtime.stop = fake_stop # type: ignore[method-assign]
|
||||
|
||||
result = runner.invoke(app, ["gateway", "stop", "--timeout", "3"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "gateway_not_running" in result.stdout
|
||||
assert fake_runtime.stop_timeout == 3
|
||||
|
||||
|
||||
def test_gateway_restart_starts_background_runtime(tmp_path):
|
||||
config = Config()
|
||||
config.gateway.port = 18793
|
||||
app, fake_runtime, _service, _calls = _test_app(tmp_path, config=config)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "restart", "--timeout", "9", "--verbose"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Gateway restarted in the background" in result.stdout
|
||||
assert fake_runtime.stop_timeout == 9
|
||||
assert fake_runtime.restarted_options == GatewayStartOptions(port=18793, verbose=True)
|
||||
|
||||
|
||||
def test_gateway_install_service_uses_service_installer(tmp_path):
|
||||
config = Config()
|
||||
config.gateway.port = 18794
|
||||
app, _runtime, service, _calls = _test_app(tmp_path, config=config)
|
||||
|
||||
result = runner.invoke(app, ["gateway", "install-service", "--dry-run", "--manager", "systemd"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Gateway service dry run" in result.stdout
|
||||
assert service.install_dry_run is True
|
||||
assert service.installed_options is not None
|
||||
assert service.installed_options.start.port == 18794
|
||||
assert service.installed_options.manager == "systemd"
|
||||
|
||||
|
||||
def test_gateway_uninstall_service_uses_service_installer(tmp_path):
|
||||
app, _runtime, service, _calls = _test_app(tmp_path)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["gateway", "uninstall-service", "--dry-run", "--name", "custom-gateway", "--manager", "systemd"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Gateway service uninstall dry run" in result.stdout
|
||||
assert service.uninstalled_name == "custom-gateway"
|
||||
assert service.uninstall_manager == "systemd"
|
||||
@@ -0,0 +1,190 @@
|
||||
import os
|
||||
import plistlib
|
||||
|
||||
from nanobot.gateway import GatewayStartOptions
|
||||
from nanobot.gateway.service import GatewayServiceInstaller, GatewayServiceOptions
|
||||
|
||||
|
||||
def _expected_launchd_domain() -> str:
|
||||
getuid = getattr(os, "getuid", None)
|
||||
if getuid is None:
|
||||
return "gui/current"
|
||||
return f"gui/{getuid()}"
|
||||
|
||||
|
||||
def test_systemd_install_dry_run_renders_user_unit(tmp_path):
|
||||
installer = GatewayServiceInstaller(platform_name="Linux", home=tmp_path)
|
||||
|
||||
result = installer.install(
|
||||
GatewayServiceOptions(
|
||||
start=GatewayStartOptions(
|
||||
port=18790,
|
||||
verbose=True,
|
||||
workspace="/tmp/nanobot workspace",
|
||||
config_path="/tmp/nanobot/config.json",
|
||||
),
|
||||
python_executable="/venv/bin/python",
|
||||
),
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.manager == "systemd"
|
||||
assert result.path == tmp_path / ".config/systemd/user/nanobot-gateway.service"
|
||||
assert ("systemctl", "--user", "daemon-reload") in result.commands
|
||||
assert ("systemctl", "--user", "enable", "nanobot-gateway.service") in result.commands
|
||||
assert ("systemctl", "--user", "restart", "nanobot-gateway.service") in result.commands
|
||||
assert result.content is not None
|
||||
assert 'WorkingDirectory="/tmp/nanobot workspace"' in result.content
|
||||
assert 'ExecStart=/venv/bin/python -m nanobot gateway --foreground --port 18790 --verbose' in result.content
|
||||
assert '--workspace "/tmp/nanobot workspace" --config /tmp/nanobot/config.json' in result.content
|
||||
|
||||
|
||||
def test_systemd_install_writes_unit_and_runs_commands(tmp_path):
|
||||
commands: list[list[str]] = []
|
||||
workspace = tmp_path / "missing-workspace"
|
||||
installer = GatewayServiceInstaller(
|
||||
platform_name="Linux",
|
||||
home=tmp_path,
|
||||
subprocess_run=lambda command, **_kwargs: commands.append(command),
|
||||
)
|
||||
|
||||
result = installer.install(
|
||||
GatewayServiceOptions(
|
||||
start=GatewayStartOptions(port=18790, workspace=str(workspace)),
|
||||
enable=False,
|
||||
start_now=True,
|
||||
python_executable="/python",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.path is not None
|
||||
assert result.path.exists()
|
||||
assert workspace.exists()
|
||||
assert commands == [
|
||||
["systemctl", "--user", "daemon-reload"],
|
||||
["systemctl", "--user", "restart", "nanobot-gateway.service"],
|
||||
]
|
||||
|
||||
|
||||
def test_launchd_install_dry_run_renders_plist(tmp_path):
|
||||
installer = GatewayServiceInstaller(platform_name="Darwin", home=tmp_path)
|
||||
|
||||
result = installer.install(
|
||||
GatewayServiceOptions(
|
||||
start=GatewayStartOptions(
|
||||
port=18791,
|
||||
workspace="/Users/test/.nanobot/workspace",
|
||||
config_path="/Users/test/.nanobot/config.json",
|
||||
),
|
||||
python_executable="/opt/homebrew/bin/python3",
|
||||
),
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.manager == "launchd"
|
||||
assert result.path == tmp_path / "Library/LaunchAgents/ai.nanobot.gateway.plist"
|
||||
assert result.content is not None
|
||||
payload = plistlib.loads(result.content.encode("utf-8"))
|
||||
assert payload["Label"] == "ai.nanobot.gateway"
|
||||
assert payload["ProgramArguments"] == [
|
||||
"/opt/homebrew/bin/python3",
|
||||
"-m",
|
||||
"nanobot",
|
||||
"gateway",
|
||||
"--foreground",
|
||||
"--port",
|
||||
"18791",
|
||||
"--workspace",
|
||||
"/Users/test/.nanobot/workspace",
|
||||
"--config",
|
||||
"/Users/test/.nanobot/config.json",
|
||||
]
|
||||
assert payload["KeepAlive"] == {"SuccessfulExit": False}
|
||||
assert ("launchctl", "bootstrap", _expected_launchd_domain(), str(result.path)) in result.commands
|
||||
|
||||
|
||||
def test_launchd_no_enable_start_still_bootstraps(tmp_path):
|
||||
installer = GatewayServiceInstaller(platform_name="Darwin", home=tmp_path)
|
||||
|
||||
result = installer.install(
|
||||
GatewayServiceOptions(
|
||||
start=GatewayStartOptions(port=18790),
|
||||
enable=False,
|
||||
start_now=True,
|
||||
),
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert result.commands[0][:2] == ("launchctl", "bootstrap")
|
||||
assert not any(command[1] == "enable" for command in result.commands)
|
||||
assert any(command[1] == "kickstart" for command in result.commands)
|
||||
|
||||
|
||||
def test_launchd_no_enable_start_reinstall_boots_out_existing_label(tmp_path):
|
||||
commands: list[list[str]] = []
|
||||
installer = GatewayServiceInstaller(
|
||||
platform_name="Darwin",
|
||||
home=tmp_path,
|
||||
subprocess_run=lambda command, **_kwargs: commands.append(command),
|
||||
)
|
||||
|
||||
result = installer.install(
|
||||
GatewayServiceOptions(
|
||||
start=GatewayStartOptions(port=18790),
|
||||
enable=False,
|
||||
start_now=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert commands[0][:2] == ["launchctl", "bootout"]
|
||||
assert commands[1][:2] == ["launchctl", "bootstrap"]
|
||||
|
||||
|
||||
def test_launchd_dry_run_does_not_require_posix_getuid(tmp_path, monkeypatch):
|
||||
monkeypatch.delattr(os, "getuid", raising=False)
|
||||
installer = GatewayServiceInstaller(platform_name="Darwin", home=tmp_path)
|
||||
|
||||
result = installer.install(
|
||||
GatewayServiceOptions(start=GatewayStartOptions(port=18790)),
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.commands[0][:3] == ("launchctl", "bootstrap", "gui/current")
|
||||
|
||||
|
||||
def test_uninstall_systemd_removes_unit_and_reloads(tmp_path):
|
||||
commands: list[list[str]] = []
|
||||
installer = GatewayServiceInstaller(
|
||||
platform_name="Linux",
|
||||
home=tmp_path,
|
||||
subprocess_run=lambda command, **_kwargs: commands.append(command),
|
||||
)
|
||||
unit = tmp_path / ".config/systemd/user/nanobot-gateway.service"
|
||||
unit.parent.mkdir(parents=True)
|
||||
unit.write_text("[Unit]\n", encoding="utf-8")
|
||||
|
||||
result = installer.uninstall()
|
||||
|
||||
assert result.ok is True
|
||||
assert not unit.exists()
|
||||
assert commands == [
|
||||
["systemctl", "--user", "disable", "--now", "nanobot-gateway.service"],
|
||||
["systemctl", "--user", "daemon-reload"],
|
||||
]
|
||||
|
||||
|
||||
def test_auto_manager_rejects_windows_services(tmp_path):
|
||||
installer = GatewayServiceInstaller(platform_name="Windows", home=tmp_path)
|
||||
|
||||
result = installer.install(
|
||||
GatewayServiceOptions(start=GatewayStartOptions(port=18790)),
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
assert result.ok is False
|
||||
assert result.message == "unsupported_service_manager:windows"
|
||||
@@ -0,0 +1,148 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from nanobot.gateway import GatewayRuntime, GatewayRuntimePaths, GatewayStartOptions
|
||||
|
||||
|
||||
class FakeProcess:
|
||||
def __init__(self, pid: int = 12345):
|
||||
self.pid = pid
|
||||
|
||||
|
||||
def _paths(tmp_path: Path) -> GatewayRuntimePaths:
|
||||
return GatewayRuntimePaths.for_instance(data_dir=tmp_path)
|
||||
|
||||
|
||||
def test_paths_use_stable_instance_suffix_for_custom_selectors(tmp_path):
|
||||
default_paths = GatewayRuntimePaths.for_instance(data_dir=tmp_path)
|
||||
first_paths = GatewayRuntimePaths.for_instance(
|
||||
data_dir=tmp_path,
|
||||
workspace="/tmp/workspace-a",
|
||||
config_path="/tmp/config-a.json",
|
||||
)
|
||||
second_paths = GatewayRuntimePaths.for_instance(
|
||||
data_dir=tmp_path,
|
||||
workspace="/tmp/workspace-b",
|
||||
config_path="/tmp/config-b.json",
|
||||
)
|
||||
|
||||
assert default_paths.state_path.name == "gateway.json"
|
||||
assert first_paths.state_path.name.startswith("gateway.")
|
||||
assert first_paths.state_path != second_paths.state_path
|
||||
assert first_paths.log_path != second_paths.log_path
|
||||
|
||||
|
||||
def test_start_background_writes_state_and_child_command(tmp_path, monkeypatch):
|
||||
calls: list[dict] = []
|
||||
|
||||
def fake_popen(command, **kwargs):
|
||||
calls.append({"command": command, "kwargs": kwargs})
|
||||
return FakeProcess()
|
||||
|
||||
runtime = GatewayRuntime(
|
||||
paths=_paths(tmp_path),
|
||||
platform_name="Linux",
|
||||
python_executable="/python",
|
||||
popen=fake_popen,
|
||||
sleep=lambda _seconds: None,
|
||||
)
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
|
||||
|
||||
result = runtime.start_background(
|
||||
GatewayStartOptions(
|
||||
port=18790,
|
||||
verbose=True,
|
||||
workspace="/tmp/workspace",
|
||||
config_path="/tmp/config.json",
|
||||
)
|
||||
)
|
||||
|
||||
assert result.ok is True
|
||||
assert result.status.running is True
|
||||
assert calls[0]["command"] == [
|
||||
"/python",
|
||||
"-m",
|
||||
"nanobot",
|
||||
"gateway",
|
||||
"--foreground",
|
||||
"--port",
|
||||
"18790",
|
||||
"--verbose",
|
||||
"--workspace",
|
||||
"/tmp/workspace",
|
||||
"--config",
|
||||
"/tmp/config.json",
|
||||
]
|
||||
assert calls[0]["kwargs"]["start_new_session"] is True
|
||||
state = json.loads(runtime.paths.state_path.read_text(encoding="utf-8"))
|
||||
assert state["pid"] == 12345
|
||||
assert state["identity"] == 12345
|
||||
assert state["port"] == 18790
|
||||
|
||||
|
||||
def test_start_background_uses_windows_process_group_flags(tmp_path, monkeypatch):
|
||||
calls: list[dict] = []
|
||||
|
||||
def fake_popen(command, **kwargs):
|
||||
calls.append({"command": command, "kwargs": kwargs})
|
||||
return FakeProcess()
|
||||
|
||||
runtime = GatewayRuntime(
|
||||
paths=_paths(tmp_path),
|
||||
platform_name="Windows",
|
||||
python_executable="python.exe",
|
||||
popen=fake_popen,
|
||||
sleep=lambda _seconds: None,
|
||||
)
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: "created-at")
|
||||
|
||||
result = runtime.start_background(GatewayStartOptions(port=18790))
|
||||
|
||||
assert result.ok is True
|
||||
assert "creationflags" in calls[0]["kwargs"]
|
||||
assert "start_new_session" not in calls[0]["kwargs"]
|
||||
|
||||
|
||||
def test_status_clears_stale_state(tmp_path, monkeypatch):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
runtime.paths.run_dir.mkdir(parents=True)
|
||||
runtime.paths.state_path.write_text('{"pid": 12345, "identity": 12345}', encoding="utf-8")
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: False)
|
||||
|
||||
status = runtime.status()
|
||||
|
||||
assert status.running is False
|
||||
assert status.reason == "stale_state"
|
||||
assert not runtime.paths.state_path.exists()
|
||||
|
||||
|
||||
def test_status_clears_state_when_pid_identity_changes(tmp_path, monkeypatch):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
runtime.paths.run_dir.mkdir(parents=True)
|
||||
runtime.paths.state_path.write_text('{"pid": 12345, "identity": 111}', encoding="utf-8")
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 222)
|
||||
|
||||
status = runtime.status()
|
||||
|
||||
assert status.running is False
|
||||
assert status.reason == "stale_state"
|
||||
assert not runtime.paths.state_path.exists()
|
||||
|
||||
|
||||
def test_stop_terminates_recorded_process(tmp_path, monkeypatch):
|
||||
runtime = GatewayRuntime(paths=_paths(tmp_path), platform_name="Linux")
|
||||
runtime.paths.run_dir.mkdir(parents=True)
|
||||
runtime.paths.state_path.write_text('{"pid": 12345, "identity": 12345}', encoding="utf-8")
|
||||
monkeypatch.setattr(runtime, "_is_pid_running", lambda _pid: True)
|
||||
monkeypatch.setattr(runtime, "_process_identity", lambda _pid: 12345)
|
||||
terminated: list[int] = []
|
||||
monkeypatch.setattr(runtime, "_terminate", lambda pid, timeout_s: terminated.append(pid))
|
||||
|
||||
result = runtime.stop()
|
||||
|
||||
assert result.ok is True
|
||||
assert terminated == [12345]
|
||||
assert not runtime.paths.state_path.exists()
|
||||
Reference in New Issue
Block a user