feat(gateway): add background and service controls

This commit is contained in:
Xubin Ren
2026-06-22 14:48:49 +08:00
parent 351aabd512
commit e3c9aff41e
11 changed files with 1684 additions and 109 deletions
+190
View File
@@ -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"
+148
View File
@@ -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()