feat(gateway): add background and service controls
This commit is contained in:
@@ -362,6 +362,7 @@ nanobot gateway
|
|||||||
```
|
```
|
||||||
|
|
||||||
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard, then send your first message there.
|
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard, then send your first message there.
|
||||||
|
Prefer not to keep a terminal open? Use `nanobot gateway --background`, then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||||
|
|
||||||
For manual or terminal-only setup, test one CLI message:
|
For manual or terminal-only setup, test one CLI message:
|
||||||
|
|
||||||
@@ -417,6 +418,8 @@ Merge this block into your existing config:
|
|||||||
nanobot gateway
|
nanobot gateway
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `nanobot gateway --background` for a local background process you can manage later with `nanobot gateway status`, `logs`, `restart`, and `stop`.
|
||||||
|
|
||||||
**3. Open the WebUI**
|
**3. Open the WebUI**
|
||||||
|
|
||||||
Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
|
Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
|
||||||
|
|||||||
+29
-4
@@ -12,7 +12,7 @@ Use this page when you know what you want to run and need the command shape. For
|
|||||||
| Check config without calling a model | `nanobot status` | Reads the default config and summarizes the active model/provider |
|
| Check config without calling a model | `nanobot status` | Reads the default config and summarizes the active model/provider |
|
||||||
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
|
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
|
||||||
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
|
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
|
||||||
| Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running while those surfaces are in use |
|
| Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running, or use `nanobot gateway --background` |
|
||||||
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
|
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
|
||||||
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
|
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
|
||||||
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
|
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
|
||||||
@@ -46,7 +46,9 @@ nanobot gateway --verbose
|
|||||||
nanobot serve --verbose
|
nanobot serve --verbose
|
||||||
```
|
```
|
||||||
|
|
||||||
Long-running commands keep working until you stop them. Press `Ctrl+C` in that terminal to stop `nanobot gateway` or `nanobot serve`.
|
Long-running commands keep working until you stop them. Press `Ctrl+C` in that terminal
|
||||||
|
to stop foreground `nanobot gateway` or `nanobot serve`. If you started the gateway
|
||||||
|
with `--background`, use `nanobot gateway stop`.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -79,15 +81,38 @@ Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
|
|||||||
|
|
||||||
## Gateway
|
## Gateway
|
||||||
|
|
||||||
`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint.
|
`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. By default it runs in the foreground, which keeps existing scripts and terminal workflows unchanged. Use `--background` when you want a local macOS, Linux, or Windows process that you can manage from the CLI.
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `nanobot gateway` | Start the gateway with config defaults |
|
| `nanobot gateway` | Start the gateway in the foreground with config defaults |
|
||||||
| `nanobot gateway --verbose` | Show verbose runtime output |
|
| `nanobot gateway --verbose` | Show verbose runtime output |
|
||||||
| `nanobot gateway --port <port>` | Override `gateway.port` for the health endpoint |
|
| `nanobot gateway --port <port>` | Override `gateway.port` for the health endpoint |
|
||||||
| `nanobot gateway --workspace <path>` | Override workspace |
|
| `nanobot gateway --workspace <path>` | Override workspace |
|
||||||
| `nanobot gateway --config <path>` | Use a specific config file |
|
| `nanobot gateway --config <path>` | Use a specific config file |
|
||||||
|
| `nanobot gateway --background` | Start the gateway as a background process |
|
||||||
|
| `nanobot gateway status` | Show the recorded background gateway PID, state file, and log file |
|
||||||
|
| `nanobot gateway logs --no-follow` | Print recent background gateway logs and exit |
|
||||||
|
| `nanobot gateway logs` | Follow background gateway logs |
|
||||||
|
| `nanobot gateway restart` | Restart the recorded background gateway with the current config |
|
||||||
|
| `nanobot gateway stop` | Stop the recorded background gateway |
|
||||||
|
| `nanobot gateway install-service` | Install a systemd user service or macOS LaunchAgent |
|
||||||
|
| `nanobot gateway install-service --dry-run` | Preview the generated service file and system commands |
|
||||||
|
| `nanobot gateway uninstall-service` | Remove the installed system service |
|
||||||
|
|
||||||
|
For custom instances, pass the same selector flags to management commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway --background --config ./bot-a/config.json --workspace ./bot-a/workspace
|
||||||
|
nanobot gateway status --config ./bot-a/config.json --workspace ./bot-a/workspace
|
||||||
|
nanobot gateway stop --config ./bot-a/config.json --workspace ./bot-a/workspace
|
||||||
|
nanobot gateway install-service --config ./bot-a/config.json --workspace ./bot-a/workspace --name bot-a
|
||||||
|
```
|
||||||
|
|
||||||
|
`--background` is a lightweight detached process. `install-service` is for
|
||||||
|
login/startup integration: Linux uses a systemd user service; macOS uses a
|
||||||
|
LaunchAgent plist. System services run the foreground gateway under the OS
|
||||||
|
supervisor rather than nesting another background process.
|
||||||
|
|
||||||
Default health endpoint:
|
Default health endpoint:
|
||||||
|
|
||||||
|
|||||||
+40
-79
@@ -106,48 +106,41 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status
|
|||||||
|
|
||||||
Run the gateway as a systemd user service so it starts automatically and restarts on failure.
|
Run the gateway as a systemd user service so it starts automatically and restarts on failure.
|
||||||
|
|
||||||
**1. Find the nanobot binary path:**
|
Preview the generated unit first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
which nanobot # e.g. /home/user/.local/bin/nanobot
|
nanobot gateway install-service --manager systemd --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Create the service file** at `~/.config/systemd/user/nanobot-gateway.service` (replace `ExecStart` path if needed):
|
Install, enable, and start it:
|
||||||
|
|
||||||
```ini
|
|
||||||
[Unit]
|
|
||||||
Description=Nanobot Gateway
|
|
||||||
After=network.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=%h/.local/bin/nanobot gateway
|
|
||||||
Restart=always
|
|
||||||
RestartSec=10
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
ProtectSystem=strict
|
|
||||||
ReadWritePaths=%h
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Enable and start:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl --user daemon-reload
|
nanobot gateway install-service --manager systemd
|
||||||
systemctl --user enable --now nanobot-gateway
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Common operations:**
|
For a custom instance, pass the same config/workspace selector you use to run the gateway:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway install-service \
|
||||||
|
--manager systemd \
|
||||||
|
--name nanobot-telegram \
|
||||||
|
--config ~/.nanobot-telegram/config.json \
|
||||||
|
--workspace ~/.nanobot-telegram/workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Common operations:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl --user status nanobot-gateway # check status
|
systemctl --user status nanobot-gateway # check status
|
||||||
systemctl --user restart nanobot-gateway # restart after config changes
|
systemctl --user restart nanobot-gateway # restart after config changes
|
||||||
journalctl --user -u nanobot-gateway -f # follow logs
|
journalctl --user -u nanobot-gateway -f # follow logs
|
||||||
|
nanobot gateway uninstall-service --manager systemd
|
||||||
```
|
```
|
||||||
|
|
||||||
If you edit the `.service` file itself, run `systemctl --user daemon-reload` before restarting.
|
The installer writes `~/.config/systemd/user/nanobot-gateway.service`, runs
|
||||||
|
`systemctl --user daemon-reload`, enables the unit, and restarts it. It uses the
|
||||||
|
current Python executable with `python -m nanobot gateway --foreground`, so the
|
||||||
|
service runs in the same environment you used to install nanobot.
|
||||||
|
|
||||||
> **Note:** User services only run while you are logged in. To keep the gateway running after logout, enable lingering:
|
> **Note:** User services only run while you are logged in. To keep the gateway running after logout, enable lingering:
|
||||||
>
|
>
|
||||||
@@ -159,70 +152,38 @@ If you edit the `.service` file itself, run `systemctl --user daemon-reload` bef
|
|||||||
|
|
||||||
Use a LaunchAgent when you want `nanobot gateway` to stay online after you log in, without keeping a terminal open.
|
Use a LaunchAgent when you want `nanobot gateway` to stay online after you log in, without keeping a terminal open.
|
||||||
|
|
||||||
**1. Get the absolute `nanobot` path:**
|
Preview the generated plist first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
which nanobot # e.g. /Users/youruser/.local/bin/nanobot
|
nanobot gateway install-service --manager launchd --dry-run
|
||||||
```
|
```
|
||||||
|
|
||||||
Use that exact path in the plist. It keeps the Python environment from your install method.
|
Install, load, enable, and start it:
|
||||||
|
|
||||||
**2. Create `~/Library/LaunchAgents/ai.nanobot.gateway.plist`:**
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>ai.nanobot.gateway</string>
|
|
||||||
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>/Users/youruser/.local/bin/nanobot</string>
|
|
||||||
<string>gateway</string>
|
|
||||||
<string>--workspace</string>
|
|
||||||
<string>/Users/youruser/.nanobot/workspace</string>
|
|
||||||
</array>
|
|
||||||
|
|
||||||
<key>WorkingDirectory</key>
|
|
||||||
<string>/Users/youruser/.nanobot/workspace</string>
|
|
||||||
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<dict>
|
|
||||||
<key>SuccessfulExit</key>
|
|
||||||
<false/>
|
|
||||||
</dict>
|
|
||||||
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>/Users/youruser/.nanobot/logs/gateway.log</string>
|
|
||||||
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>/Users/youruser/.nanobot/logs/gateway.error.log</string>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
```
|
|
||||||
|
|
||||||
**3. Load and start it:**
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
mkdir -p ~/Library/LaunchAgents ~/.nanobot/logs
|
nanobot gateway install-service --manager launchd
|
||||||
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.nanobot.gateway.plist
|
|
||||||
launchctl enable gui/$(id -u)/ai.nanobot.gateway
|
|
||||||
launchctl kickstart -k gui/$(id -u)/ai.nanobot.gateway
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Common operations:**
|
For a custom instance:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nanobot gateway install-service \
|
||||||
|
--manager launchd \
|
||||||
|
--name nanobot-telegram \
|
||||||
|
--config ~/.nanobot-telegram/config.json \
|
||||||
|
--workspace ~/.nanobot-telegram/workspace
|
||||||
|
```
|
||||||
|
|
||||||
|
Common operations:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
launchctl list | grep ai.nanobot.gateway
|
launchctl list | grep ai.nanobot.gateway
|
||||||
launchctl kickstart -k gui/$(id -u)/ai.nanobot.gateway # restart
|
launchctl kickstart -k gui/$(id -u)/ai.nanobot.gateway
|
||||||
launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.nanobot.gateway.plist
|
nanobot gateway uninstall-service --manager launchd
|
||||||
```
|
```
|
||||||
|
|
||||||
After editing the plist, run `launchctl bootout ...` and `launchctl bootstrap ...` again.
|
The installer writes `~/Library/LaunchAgents/ai.nanobot.gateway.plist`, uses the
|
||||||
|
current Python executable with `python -m nanobot gateway --foreground`, and
|
||||||
|
writes LaunchAgent logs under `~/.nanobot/logs/`.
|
||||||
|
|
||||||
> **Note:** if startup fails with "address already in use", stop the manually started `nanobot gateway` process first.
|
> **Note:** if startup fails with "address already in use", stop the manually started `nanobot gateway` process first.
|
||||||
|
|||||||
+12
-26
@@ -50,6 +50,7 @@ from rich.text import Text # noqa: E402
|
|||||||
|
|
||||||
from nanobot import __logo__, __version__ # noqa: E402
|
from nanobot import __logo__, __version__ # noqa: E402
|
||||||
from nanobot.agent.loop import AgentLoop # noqa: E402
|
from nanobot.agent.loop import AgentLoop # noqa: E402
|
||||||
|
from nanobot.cli.gateway import create_gateway_app # noqa: E402
|
||||||
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
|
from nanobot.cli.stream import StreamRenderer, ThinkingSpinner # noqa: E402
|
||||||
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
|
from nanobot.config.paths import get_workspace_path, is_default_workspace # noqa: E402
|
||||||
from nanobot.config.schema import Config # noqa: E402
|
from nanobot.config.schema import Config # noqa: E402
|
||||||
@@ -714,32 +715,6 @@ def serve(
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
@app.command()
|
|
||||||
def gateway(
|
|
||||||
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
|
||||||
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
|
||||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
|
||||||
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
|
||||||
):
|
|
||||||
"""Start the nanobot gateway."""
|
|
||||||
if verbose:
|
|
||||||
logger.remove(_log_handler_id)
|
|
||||||
logger.add(
|
|
||||||
sys.stderr,
|
|
||||||
format=(
|
|
||||||
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
|
||||||
"<level>{level: <5}</level> | "
|
|
||||||
"<cyan>{extra[channel]}</cyan> | "
|
|
||||||
"<level>{message}</level>"
|
|
||||||
),
|
|
||||||
level="DEBUG",
|
|
||||||
colorize=None,
|
|
||||||
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
|
|
||||||
)
|
|
||||||
cfg = _load_runtime_config(config, workspace)
|
|
||||||
_run_gateway(cfg, port=port)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_gateway(
|
def _run_gateway(
|
||||||
config: Config,
|
config: Config,
|
||||||
*,
|
*,
|
||||||
@@ -1163,6 +1138,17 @@ def _run_gateway(
|
|||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
app.add_typer(
|
||||||
|
create_gateway_app(
|
||||||
|
console=console,
|
||||||
|
log_handler_id=_log_handler_id,
|
||||||
|
load_runtime_config=_load_runtime_config,
|
||||||
|
run_gateway=_run_gateway,
|
||||||
|
),
|
||||||
|
name="gateway",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Agent Commands
|
# Agent Commands
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
"""Typer commands for foreground and background gateway control."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import typer
|
||||||
|
from loguru import logger
|
||||||
|
from rich.console import Console
|
||||||
|
|
||||||
|
from nanobot.config.schema import Config
|
||||||
|
from nanobot.gateway import (
|
||||||
|
GatewayRuntime,
|
||||||
|
GatewayRuntimePaths,
|
||||||
|
GatewayStartOptions,
|
||||||
|
GatewayStatus,
|
||||||
|
)
|
||||||
|
from nanobot.gateway.service import (
|
||||||
|
GatewayServiceInstaller,
|
||||||
|
GatewayServiceOptions,
|
||||||
|
GatewayServiceResult,
|
||||||
|
ServiceManagerKind,
|
||||||
|
)
|
||||||
|
|
||||||
|
RuntimeConfigLoader = Callable[[str | None, str | None], Config]
|
||||||
|
GatewayRunner = Callable[..., None]
|
||||||
|
GatewayRuntimeFactory = Callable[..., Any]
|
||||||
|
GatewayServiceFactory = Callable[[], Any]
|
||||||
|
|
||||||
|
|
||||||
|
def create_gateway_app(
|
||||||
|
*,
|
||||||
|
console: Console,
|
||||||
|
log_handler_id: int,
|
||||||
|
load_runtime_config: RuntimeConfigLoader,
|
||||||
|
run_gateway: GatewayRunner,
|
||||||
|
runtime_factory: GatewayRuntimeFactory | None = None,
|
||||||
|
service_factory: GatewayServiceFactory | None = None,
|
||||||
|
) -> typer.Typer:
|
||||||
|
gateway_app = typer.Typer(
|
||||||
|
help="Start and manage the nanobot gateway.",
|
||||||
|
invoke_without_command=True,
|
||||||
|
no_args_is_help=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def configure_logging(verbose: bool) -> None:
|
||||||
|
if not verbose:
|
||||||
|
return
|
||||||
|
logger.remove(log_handler_id)
|
||||||
|
logger.add(
|
||||||
|
sys.stderr,
|
||||||
|
format=(
|
||||||
|
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
||||||
|
"<level>{level: <5}</level> | "
|
||||||
|
"<cyan>{extra[channel]}</cyan> | "
|
||||||
|
"<level>{message}</level>"
|
||||||
|
),
|
||||||
|
level="DEBUG",
|
||||||
|
colorize=None,
|
||||||
|
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def runtime_for_instance(*, workspace: str | None = None, config: str | None = None):
|
||||||
|
if runtime_factory is not None:
|
||||||
|
return runtime_factory(workspace=workspace, config=config)
|
||||||
|
config_path = str(Path(config).expanduser().resolve(strict=False)) if config else None
|
||||||
|
workspace_path = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
|
||||||
|
data_dir = Path(config_path).parent if config_path else None
|
||||||
|
return GatewayRuntime(
|
||||||
|
paths=GatewayRuntimePaths.for_instance(
|
||||||
|
data_dir=data_dir,
|
||||||
|
workspace=workspace_path,
|
||||||
|
config_path=config_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def service_installer():
|
||||||
|
return service_factory() if service_factory is not None else GatewayServiceInstaller()
|
||||||
|
|
||||||
|
def start_options(
|
||||||
|
*,
|
||||||
|
port: int | None,
|
||||||
|
verbose: bool,
|
||||||
|
workspace: str | None,
|
||||||
|
config: str | None,
|
||||||
|
) -> GatewayStartOptions:
|
||||||
|
cfg = load_runtime_config(config, workspace)
|
||||||
|
resolved_config = str(Path(config).expanduser().resolve()) if config else None
|
||||||
|
resolved_workspace = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
|
||||||
|
return GatewayStartOptions(
|
||||||
|
port=port if port is not None else cfg.gateway.port,
|
||||||
|
verbose=verbose,
|
||||||
|
workspace=resolved_workspace,
|
||||||
|
config_path=resolved_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
def print_status(status: GatewayStatus) -> None:
|
||||||
|
console.print(f"Running: {'yes' if status.running else 'no'}")
|
||||||
|
console.print(f"Reason: {status.reason}")
|
||||||
|
if status.pid is not None:
|
||||||
|
console.print(f"PID: {status.pid}")
|
||||||
|
if status.port is not None:
|
||||||
|
console.print(f"Port: {status.port}")
|
||||||
|
if status.started_at is not None:
|
||||||
|
console.print(f"Started At: {status.started_at}")
|
||||||
|
console.print(f"State: {status.state_path}")
|
||||||
|
console.print(f"Logs: {status.log_path}")
|
||||||
|
|
||||||
|
def print_service_result(result: GatewayServiceResult) -> None:
|
||||||
|
console.print(f"Manager: {result.manager}")
|
||||||
|
if result.path is not None:
|
||||||
|
console.print(f"Path: {result.path}")
|
||||||
|
if result.commands:
|
||||||
|
console.print("Commands:")
|
||||||
|
for command in result.commands:
|
||||||
|
console.print(" " + " ".join(command))
|
||||||
|
if result.content is not None:
|
||||||
|
console.print()
|
||||||
|
console.print(result.content)
|
||||||
|
|
||||||
|
@gateway_app.callback(invoke_without_command=True)
|
||||||
|
def gateway(
|
||||||
|
ctx: typer.Context,
|
||||||
|
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
|
foreground: bool = typer.Option(False, "--foreground", help="Run in the foreground"),
|
||||||
|
background: bool = typer.Option(False, "--background", help="Start as a background process"),
|
||||||
|
) -> None:
|
||||||
|
"""Start the nanobot gateway."""
|
||||||
|
if ctx.invoked_subcommand is not None:
|
||||||
|
return
|
||||||
|
if foreground and background:
|
||||||
|
console.print("[red]Error: --foreground and --background cannot be used together.[/red]")
|
||||||
|
raise typer.Exit(1)
|
||||||
|
if background:
|
||||||
|
runtime = runtime_for_instance(workspace=workspace, config=config)
|
||||||
|
result = runtime.start_background(
|
||||||
|
start_options(
|
||||||
|
port=port,
|
||||||
|
verbose=verbose,
|
||||||
|
workspace=workspace,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if result.ok:
|
||||||
|
console.print("[green]Gateway started in the background.[/green]")
|
||||||
|
print_status(result.status)
|
||||||
|
return
|
||||||
|
console.print(f"[yellow]Gateway was not started: {result.message}[/yellow]")
|
||||||
|
print_status(result.status)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
configure_logging(verbose)
|
||||||
|
cfg = load_runtime_config(config, workspace)
|
||||||
|
run_gateway(cfg, port=port)
|
||||||
|
|
||||||
|
@gateway_app.command("status")
|
||||||
|
def gateway_status(
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
|
) -> None:
|
||||||
|
"""Show the background gateway status."""
|
||||||
|
print_status(runtime_for_instance(workspace=workspace, config=config).status())
|
||||||
|
|
||||||
|
@gateway_app.command("logs")
|
||||||
|
def gateway_logs(
|
||||||
|
tail: int = typer.Option(200, "--tail", help="Number of recent lines to show"),
|
||||||
|
follow: bool = typer.Option(True, "--follow/--no-follow", help="Follow new log output"),
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
|
) -> None:
|
||||||
|
"""Show background gateway logs."""
|
||||||
|
runtime = runtime_for_instance(workspace=workspace, config=config)
|
||||||
|
if follow:
|
||||||
|
raise typer.Exit(runtime.follow_logs(tail=tail))
|
||||||
|
lines = runtime.read_log_tail(tail=tail)
|
||||||
|
if not lines:
|
||||||
|
console.print("[dim]No gateway log output available yet.[/dim]")
|
||||||
|
return
|
||||||
|
for line in lines:
|
||||||
|
console.print(line)
|
||||||
|
|
||||||
|
@gateway_app.command("stop")
|
||||||
|
def gateway_stop(
|
||||||
|
timeout: int = typer.Option(20, "--timeout", help="Stop timeout in seconds"),
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
|
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)
|
||||||
|
if result.ok:
|
||||||
|
console.print("[green]Gateway stopped.[/green]")
|
||||||
|
else:
|
||||||
|
console.print(f"[yellow]Gateway was not stopped: {result.message}[/yellow]")
|
||||||
|
print_status(result.status)
|
||||||
|
if not result.ok and result.message != "gateway_not_running":
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
@gateway_app.command("restart")
|
||||||
|
def gateway_restart(
|
||||||
|
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
|
timeout: int = typer.Option(20, "--timeout", help="Restart timeout in seconds"),
|
||||||
|
) -> None:
|
||||||
|
"""Restart the background gateway."""
|
||||||
|
runtime = runtime_for_instance(workspace=workspace, config=config)
|
||||||
|
result = runtime.restart(
|
||||||
|
start_options(
|
||||||
|
port=port,
|
||||||
|
verbose=verbose,
|
||||||
|
workspace=workspace,
|
||||||
|
config=config,
|
||||||
|
),
|
||||||
|
timeout_s=timeout,
|
||||||
|
)
|
||||||
|
if result.ok:
|
||||||
|
console.print("[green]Gateway restarted in the background.[/green]")
|
||||||
|
print_status(result.status)
|
||||||
|
return
|
||||||
|
console.print(f"[red]Gateway restart failed: {result.message}[/red]")
|
||||||
|
print_status(result.status)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
@gateway_app.command("install-service")
|
||||||
|
def gateway_install_service(
|
||||||
|
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
|
||||||
|
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
|
||||||
|
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
|
||||||
|
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
|
||||||
|
name: str = typer.Option("nanobot-gateway", "--name", help="Service name"),
|
||||||
|
manager: ServiceManagerKind = typer.Option("auto", "--manager", help="auto, systemd, or launchd"),
|
||||||
|
enable: bool = typer.Option(True, "--enable/--no-enable", help="Enable the service after writing it"),
|
||||||
|
start_now: bool = typer.Option(True, "--start/--no-start", help="Start the service after writing it"),
|
||||||
|
dry_run: bool = typer.Option(False, "--dry-run", help="Print generated service without installing"),
|
||||||
|
) -> None:
|
||||||
|
"""Install a systemd user service or macOS LaunchAgent for the gateway."""
|
||||||
|
options = GatewayServiceOptions(
|
||||||
|
start=start_options(port=port, verbose=verbose, workspace=workspace, config=config),
|
||||||
|
name=name,
|
||||||
|
manager=manager,
|
||||||
|
enable=enable,
|
||||||
|
start_now=start_now,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = service_installer().install(options, dry_run=dry_run)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
console.print(f"[red]Service install failed while running: {' '.join(exc.cmd)}[/red]")
|
||||||
|
raise typer.Exit(exc.returncode or 1) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
console.print(f"[red]Service install failed: {exc}[/red]")
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
if result.ok:
|
||||||
|
console.print("[green]Gateway service installed.[/green]" if not dry_run else "[green]Gateway service dry run.[/green]")
|
||||||
|
print_service_result(result)
|
||||||
|
return
|
||||||
|
console.print(f"[red]Gateway service was not installed: {result.message}[/red]")
|
||||||
|
print_service_result(result)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
@gateway_app.command("uninstall-service")
|
||||||
|
def gateway_uninstall_service(
|
||||||
|
name: str = typer.Option("nanobot-gateway", "--name", help="Service name"),
|
||||||
|
manager: ServiceManagerKind = typer.Option("auto", "--manager", help="auto, systemd, or launchd"),
|
||||||
|
dry_run: bool = typer.Option(False, "--dry-run", help="Print actions without uninstalling"),
|
||||||
|
) -> None:
|
||||||
|
"""Uninstall the system gateway service."""
|
||||||
|
try:
|
||||||
|
result = service_installer().uninstall(name=name, manager=manager, dry_run=dry_run)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
console.print(f"[red]Service uninstall failed while running: {' '.join(exc.cmd)}[/red]")
|
||||||
|
raise typer.Exit(exc.returncode or 1) from exc
|
||||||
|
except OSError as exc:
|
||||||
|
console.print(f"[red]Service uninstall failed: {exc}[/red]")
|
||||||
|
raise typer.Exit(1) from exc
|
||||||
|
if result.ok:
|
||||||
|
console.print("[green]Gateway service uninstalled.[/green]" if not dry_run else "[green]Gateway service uninstall dry run.[/green]")
|
||||||
|
print_service_result(result)
|
||||||
|
return
|
||||||
|
console.print(f"[red]Gateway service was not uninstalled: {result.message}[/red]")
|
||||||
|
print_service_result(result)
|
||||||
|
raise typer.Exit(1)
|
||||||
|
|
||||||
|
return gateway_app
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Lightweight background runtime for the nanobot gateway."""
|
||||||
|
|
||||||
|
from nanobot.gateway.runtime import (
|
||||||
|
GatewayRuntime,
|
||||||
|
GatewayRuntimePaths,
|
||||||
|
GatewayStartOptions,
|
||||||
|
GatewayStatus,
|
||||||
|
RuntimeResult,
|
||||||
|
build_gateway_command,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"GatewayRuntime",
|
||||||
|
"GatewayRuntimePaths",
|
||||||
|
"GatewayStartOptions",
|
||||||
|
"GatewayStatus",
|
||||||
|
"RuntimeResult",
|
||||||
|
"build_gateway_command",
|
||||||
|
]
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
"""Background process control for ``nanobot gateway``.
|
||||||
|
|
||||||
|
This module intentionally stays small: the CLI owns command wording, while this
|
||||||
|
runtime owns process state, log files, and platform-specific detach/stop details.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ctypes
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from nanobot.config.paths import get_data_dir
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GatewayStartOptions:
|
||||||
|
"""Options needed to start a background gateway instance."""
|
||||||
|
|
||||||
|
port: int
|
||||||
|
verbose: bool = False
|
||||||
|
workspace: str | None = None
|
||||||
|
config_path: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GatewayStatus:
|
||||||
|
"""Current background gateway status."""
|
||||||
|
|
||||||
|
running: bool
|
||||||
|
pid: int | None
|
||||||
|
state_path: Path
|
||||||
|
log_path: Path
|
||||||
|
started_at: str | None = None
|
||||||
|
port: int | None = None
|
||||||
|
command: tuple[str, ...] = ()
|
||||||
|
reason: str = "not_started"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RuntimeResult:
|
||||||
|
"""Result from a gateway runtime control operation."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
status: GatewayStatus
|
||||||
|
|
||||||
|
|
||||||
|
def build_gateway_command(python_executable: str, options: GatewayStartOptions) -> list[str]:
|
||||||
|
"""Build a foreground gateway command for process supervisors."""
|
||||||
|
command = [
|
||||||
|
python_executable,
|
||||||
|
"-m",
|
||||||
|
"nanobot",
|
||||||
|
"gateway",
|
||||||
|
"--foreground",
|
||||||
|
"--port",
|
||||||
|
str(options.port),
|
||||||
|
]
|
||||||
|
if options.verbose:
|
||||||
|
command.append("--verbose")
|
||||||
|
if options.workspace:
|
||||||
|
command.extend(["--workspace", options.workspace])
|
||||||
|
if options.config_path:
|
||||||
|
command.extend(["--config", options.config_path])
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GatewayRuntimePaths:
|
||||||
|
"""Filesystem layout for one gateway runtime instance."""
|
||||||
|
|
||||||
|
run_dir: Path
|
||||||
|
logs_dir: Path
|
||||||
|
state_path: Path
|
||||||
|
log_path: Path
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def for_instance(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
data_dir: Path | None = None,
|
||||||
|
workspace: str | None = None,
|
||||||
|
config_path: str | None = None,
|
||||||
|
) -> "GatewayRuntimePaths":
|
||||||
|
base = data_dir or get_data_dir()
|
||||||
|
suffix = _instance_suffix(workspace=workspace, config_path=config_path)
|
||||||
|
run_dir = base / "run"
|
||||||
|
logs_dir = base / "logs"
|
||||||
|
stem = "gateway" if suffix is None else f"gateway.{suffix}"
|
||||||
|
return cls(
|
||||||
|
run_dir=run_dir,
|
||||||
|
logs_dir=logs_dir,
|
||||||
|
state_path=run_dir / f"{stem}.json",
|
||||||
|
log_path=logs_dir / f"{stem}.log",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayRuntime:
|
||||||
|
"""Manage a background ``nanobot gateway`` process."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
paths: GatewayRuntimePaths | None = None,
|
||||||
|
platform_name: str | None = None,
|
||||||
|
python_executable: str | None = None,
|
||||||
|
popen: Callable[..., Any] = subprocess.Popen,
|
||||||
|
subprocess_run: Callable[..., Any] = subprocess.run,
|
||||||
|
sleep: Callable[[float], None] = time.sleep,
|
||||||
|
) -> None:
|
||||||
|
self.paths = paths or GatewayRuntimePaths.for_instance()
|
||||||
|
self.platform_name = platform_name or _platform_name()
|
||||||
|
self.python_executable = python_executable or sys.executable
|
||||||
|
self._popen = popen
|
||||||
|
self._subprocess_run = subprocess_run
|
||||||
|
self._sleep = sleep
|
||||||
|
|
||||||
|
def start_background(self, options: GatewayStartOptions) -> RuntimeResult:
|
||||||
|
"""Start gateway as a detached background process."""
|
||||||
|
current = self.status()
|
||||||
|
if current.running:
|
||||||
|
return RuntimeResult(False, "gateway_already_running", current)
|
||||||
|
|
||||||
|
command = self._build_child_command(options)
|
||||||
|
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
with self.paths.log_path.open("a", encoding="utf-8") as log_handle:
|
||||||
|
process = self._popen(
|
||||||
|
command,
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=log_handle,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
**self._popen_platform_kwargs(),
|
||||||
|
)
|
||||||
|
|
||||||
|
pid = int(process.pid)
|
||||||
|
self._sleep(0.2)
|
||||||
|
if not self._is_pid_running(pid):
|
||||||
|
return RuntimeResult(False, "gateway_exited_during_startup", self.status())
|
||||||
|
|
||||||
|
identity = self._process_identity(pid)
|
||||||
|
self._write_state(
|
||||||
|
{
|
||||||
|
"pid": pid,
|
||||||
|
"identity": identity,
|
||||||
|
"started_at": _utc_now(),
|
||||||
|
"platform": self.platform_name,
|
||||||
|
"port": options.port,
|
||||||
|
"workspace": options.workspace,
|
||||||
|
"config_path": options.config_path,
|
||||||
|
"command": command,
|
||||||
|
"log_path": str(self.paths.log_path),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return RuntimeResult(True, "gateway_started_background", self.status())
|
||||||
|
|
||||||
|
def stop(self, *, timeout_s: int = 20) -> RuntimeResult:
|
||||||
|
"""Stop the recorded background gateway process."""
|
||||||
|
status = self.status()
|
||||||
|
if not status.pid:
|
||||||
|
return RuntimeResult(False, "gateway_not_running", status)
|
||||||
|
|
||||||
|
state = self._read_state()
|
||||||
|
if not self._record_matches_process(state, status.pid):
|
||||||
|
self._clear_state()
|
||||||
|
return RuntimeResult(False, "gateway_state_stale", self.status(reason="stale_state"))
|
||||||
|
|
||||||
|
self._terminate(status.pid, timeout_s=timeout_s)
|
||||||
|
self._clear_state()
|
||||||
|
return RuntimeResult(True, "gateway_stopped", self.status(reason="stopped"))
|
||||||
|
|
||||||
|
def restart(self, options: GatewayStartOptions, *, timeout_s: int = 20) -> RuntimeResult:
|
||||||
|
"""Restart the background gateway."""
|
||||||
|
stop_result = self.stop(timeout_s=timeout_s)
|
||||||
|
if not stop_result.ok and stop_result.message not in {"gateway_not_running", "gateway_state_stale"}:
|
||||||
|
return stop_result
|
||||||
|
return self.start_background(options)
|
||||||
|
|
||||||
|
def status(self, *, reason: str | None = None) -> GatewayStatus:
|
||||||
|
"""Return live status, clearing stale state when needed."""
|
||||||
|
state = self._read_state()
|
||||||
|
pid = _as_int(state.get("pid")) if state else None
|
||||||
|
if pid is None:
|
||||||
|
return GatewayStatus(
|
||||||
|
running=False,
|
||||||
|
pid=None,
|
||||||
|
state_path=self.paths.state_path,
|
||||||
|
log_path=self.paths.log_path,
|
||||||
|
reason=reason or "not_started",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not self._is_pid_running(pid) or not self._record_matches_process(state, pid):
|
||||||
|
self._clear_state()
|
||||||
|
return GatewayStatus(
|
||||||
|
running=False,
|
||||||
|
pid=None,
|
||||||
|
state_path=self.paths.state_path,
|
||||||
|
log_path=self.paths.log_path,
|
||||||
|
reason=reason or "stale_state",
|
||||||
|
)
|
||||||
|
|
||||||
|
command = state.get("command")
|
||||||
|
return GatewayStatus(
|
||||||
|
running=True,
|
||||||
|
pid=pid,
|
||||||
|
state_path=self.paths.state_path,
|
||||||
|
log_path=self.paths.log_path,
|
||||||
|
started_at=_as_str(state.get("started_at")),
|
||||||
|
port=_as_int(state.get("port")),
|
||||||
|
command=tuple(command) if isinstance(command, list) else (),
|
||||||
|
reason=reason or "running",
|
||||||
|
)
|
||||||
|
|
||||||
|
def read_log_tail(self, *, tail: int = 200) -> list[str]:
|
||||||
|
"""Return the last ``tail`` log lines."""
|
||||||
|
if tail <= 0 or not self.paths.log_path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
lines = self.paths.log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
return lines[-tail:]
|
||||||
|
|
||||||
|
def follow_logs(self, *, tail: int = 200) -> int:
|
||||||
|
"""Print existing log tail and follow new log lines."""
|
||||||
|
for line in self.read_log_tail(tail=tail):
|
||||||
|
print(line)
|
||||||
|
self.paths.logs_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.paths.log_path.touch(exist_ok=True)
|
||||||
|
try:
|
||||||
|
with self.paths.log_path.open("r", encoding="utf-8", errors="replace") as handle:
|
||||||
|
handle.seek(0, os.SEEK_END)
|
||||||
|
while True:
|
||||||
|
line = handle.readline()
|
||||||
|
if line:
|
||||||
|
print(line.rstrip("\n"))
|
||||||
|
else:
|
||||||
|
self._sleep(0.5)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
return 130
|
||||||
|
|
||||||
|
def _build_child_command(self, options: GatewayStartOptions) -> list[str]:
|
||||||
|
return build_gateway_command(self.python_executable, options)
|
||||||
|
|
||||||
|
def _popen_platform_kwargs(self) -> dict[str, Any]:
|
||||||
|
if self.platform_name == "Windows":
|
||||||
|
flags = 0
|
||||||
|
flags |= getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
||||||
|
flags |= getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
return {"creationflags": flags}
|
||||||
|
return {"start_new_session": True}
|
||||||
|
|
||||||
|
def _terminate(self, pid: int, *, timeout_s: int) -> None:
|
||||||
|
if self.platform_name == "Windows":
|
||||||
|
self._terminate_windows(pid, timeout_s=timeout_s)
|
||||||
|
else:
|
||||||
|
self._terminate_posix(pid, timeout_s=timeout_s)
|
||||||
|
|
||||||
|
def _terminate_posix(self, pid: int, *, timeout_s: int) -> None:
|
||||||
|
try:
|
||||||
|
pgid = os.getpgid(pid)
|
||||||
|
except OSError:
|
||||||
|
pgid = None
|
||||||
|
try:
|
||||||
|
if pgid is not None:
|
||||||
|
os.killpg(pgid, signal.SIGTERM)
|
||||||
|
else:
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return
|
||||||
|
if self._wait_for_exit(pid, timeout_s):
|
||||||
|
return
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
if pgid is not None:
|
||||||
|
os.killpg(pgid, signal.SIGKILL)
|
||||||
|
else:
|
||||||
|
os.kill(pid, signal.SIGKILL)
|
||||||
|
self._wait_for_exit(pid, 2)
|
||||||
|
|
||||||
|
def _terminate_windows(self, pid: int, *, timeout_s: int) -> None:
|
||||||
|
ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", None)
|
||||||
|
if ctrl_break is not None:
|
||||||
|
with suppress(ProcessLookupError):
|
||||||
|
os.kill(pid, ctrl_break)
|
||||||
|
if self._wait_for_exit(pid, timeout_s):
|
||||||
|
return
|
||||||
|
self._subprocess_run(["taskkill", "/PID", str(pid), "/T"], check=False)
|
||||||
|
if self._wait_for_exit(pid, 2):
|
||||||
|
return
|
||||||
|
self._subprocess_run(["taskkill", "/PID", str(pid), "/T", "/F"], check=False)
|
||||||
|
self._wait_for_exit(pid, 2)
|
||||||
|
|
||||||
|
def _wait_for_exit(self, pid: int, timeout_s: int | float) -> bool:
|
||||||
|
deadline = time.monotonic() + max(float(timeout_s), 0.0)
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
if not self._is_pid_running(pid):
|
||||||
|
return True
|
||||||
|
self._sleep(0.1)
|
||||||
|
return not self._is_pid_running(pid)
|
||||||
|
|
||||||
|
def _is_pid_running(self, pid: int) -> bool:
|
||||||
|
if pid <= 0:
|
||||||
|
return False
|
||||||
|
if self.platform_name == "Windows":
|
||||||
|
return _windows_process_identity(pid) is not None
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _process_identity(self, pid: int) -> str | int | None:
|
||||||
|
if self.platform_name == "Windows":
|
||||||
|
return _windows_process_identity(pid)
|
||||||
|
try:
|
||||||
|
return os.getpgid(pid)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _record_matches_process(self, state: dict[str, Any] | None, pid: int) -> bool:
|
||||||
|
if not state:
|
||||||
|
return False
|
||||||
|
recorded = state.get("identity")
|
||||||
|
if recorded is None:
|
||||||
|
return True
|
||||||
|
return recorded == self._process_identity(pid)
|
||||||
|
|
||||||
|
def _read_state(self) -> dict[str, Any] | None:
|
||||||
|
try:
|
||||||
|
with self.paths.state_path.open(encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
except (OSError, json.JSONDecodeError, ValueError):
|
||||||
|
return None
|
||||||
|
return payload if isinstance(payload, dict) else None
|
||||||
|
|
||||||
|
def _write_state(self, payload: dict[str, Any]) -> None:
|
||||||
|
self.paths.run_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp_name = tempfile.mkstemp(
|
||||||
|
prefix=f"{self.paths.state_path.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
dir=self.paths.run_dir,
|
||||||
|
)
|
||||||
|
tmp_path = Path(tmp_name)
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, indent=2, ensure_ascii=False)
|
||||||
|
handle.write("\n")
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
tmp_path.replace(self.paths.state_path)
|
||||||
|
finally:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def _clear_state(self) -> None:
|
||||||
|
self.paths.state_path.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
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
return hashlib.sha1(raw.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_name() -> str:
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
return "Windows"
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return "Darwin"
|
||||||
|
return "Linux"
|
||||||
|
|
||||||
|
|
||||||
|
def _utc_now() -> str:
|
||||||
|
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
|
def _as_int(value: object) -> int | None:
|
||||||
|
if isinstance(value, int):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _as_str(value: object) -> str | None:
|
||||||
|
return value if isinstance(value, str) else None
|
||||||
|
|
||||||
|
|
||||||
|
def _windows_process_identity(pid: int) -> str | None:
|
||||||
|
if os.name != "nt":
|
||||||
|
return None
|
||||||
|
|
||||||
|
class FileTime(ctypes.Structure):
|
||||||
|
_fields_ = [("low", ctypes.c_uint32), ("high", ctypes.c_uint32)]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def value(self) -> int:
|
||||||
|
return (int(self.high) << 32) | int(self.low)
|
||||||
|
|
||||||
|
process_query_limited_information = 0x1000
|
||||||
|
kernel32 = ctypes.windll.kernel32
|
||||||
|
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
|
||||||
|
if not handle:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
creation_time = FileTime()
|
||||||
|
exit_time = FileTime()
|
||||||
|
kernel_time = FileTime()
|
||||||
|
user_time = FileTime()
|
||||||
|
ok = kernel32.GetProcessTimes(
|
||||||
|
handle,
|
||||||
|
ctypes.byref(creation_time),
|
||||||
|
ctypes.byref(exit_time),
|
||||||
|
ctypes.byref(kernel_time),
|
||||||
|
ctypes.byref(user_time),
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
return None
|
||||||
|
exit_code = ctypes.c_uint32()
|
||||||
|
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
|
||||||
|
return None
|
||||||
|
if exit_code.value != 259:
|
||||||
|
return None
|
||||||
|
return str(creation_time.value)
|
||||||
|
finally:
|
||||||
|
kernel32.CloseHandle(handle)
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""Install and manage OS-level gateway services."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import plistlib
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from nanobot.gateway import GatewayStartOptions, build_gateway_command
|
||||||
|
|
||||||
|
ServiceManagerKind = Literal["auto", "systemd", "launchd"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GatewayServiceOptions:
|
||||||
|
"""Inputs used to render one system service."""
|
||||||
|
|
||||||
|
start: GatewayStartOptions
|
||||||
|
name: str = "nanobot-gateway"
|
||||||
|
manager: ServiceManagerKind = "auto"
|
||||||
|
enable: bool = True
|
||||||
|
start_now: bool = True
|
||||||
|
python_executable: str = sys.executable
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GatewayServiceResult:
|
||||||
|
"""Result from service install/uninstall operations."""
|
||||||
|
|
||||||
|
ok: bool
|
||||||
|
message: str
|
||||||
|
manager: str
|
||||||
|
path: Path | None
|
||||||
|
commands: tuple[tuple[str, ...], ...] = ()
|
||||||
|
content: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GatewayServiceInstaller:
|
||||||
|
"""Render and install systemd user services or macOS LaunchAgents."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
platform_name: str | None = None,
|
||||||
|
subprocess_run: Callable[..., Any] = subprocess.run,
|
||||||
|
home: Path | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.platform_name = platform_name or _platform_name()
|
||||||
|
self._subprocess_run = subprocess_run
|
||||||
|
self.home = home or Path.home()
|
||||||
|
|
||||||
|
def install(self, options: GatewayServiceOptions, *, dry_run: bool = False) -> GatewayServiceResult:
|
||||||
|
manager = self._resolve_manager(options.manager)
|
||||||
|
if manager == "systemd":
|
||||||
|
return self._install_systemd(options, dry_run=dry_run)
|
||||||
|
if manager == "launchd":
|
||||||
|
return self._install_launchd(options, dry_run=dry_run)
|
||||||
|
return GatewayServiceResult(False, f"unsupported_service_manager:{manager}", manager, None)
|
||||||
|
|
||||||
|
def uninstall(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
name: str = "nanobot-gateway",
|
||||||
|
manager: ServiceManagerKind = "auto",
|
||||||
|
dry_run: bool = False,
|
||||||
|
) -> GatewayServiceResult:
|
||||||
|
resolved = self._resolve_manager(manager)
|
||||||
|
if resolved == "systemd":
|
||||||
|
return self._uninstall_systemd(name=name, dry_run=dry_run)
|
||||||
|
if resolved == "launchd":
|
||||||
|
return self._uninstall_launchd(name=name, dry_run=dry_run)
|
||||||
|
return GatewayServiceResult(False, f"unsupported_service_manager:{resolved}", resolved, None)
|
||||||
|
|
||||||
|
def _install_systemd(
|
||||||
|
self,
|
||||||
|
options: GatewayServiceOptions,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> GatewayServiceResult:
|
||||||
|
unit_name = _systemd_unit_name(options.name)
|
||||||
|
path = self.home / ".config" / "systemd" / "user" / unit_name
|
||||||
|
command = build_gateway_command(options.python_executable, options.start)
|
||||||
|
content = _systemd_unit_content(
|
||||||
|
description=f"Nanobot Gateway ({options.name})",
|
||||||
|
command=command,
|
||||||
|
working_directory=_working_directory_text(options.start),
|
||||||
|
)
|
||||||
|
commands: list[tuple[str, ...]] = [("systemctl", "--user", "daemon-reload")]
|
||||||
|
if options.enable:
|
||||||
|
commands.append(("systemctl", "--user", "enable", unit_name))
|
||||||
|
if options.start_now:
|
||||||
|
commands.append(("systemctl", "--user", "restart", unit_name))
|
||||||
|
if dry_run:
|
||||||
|
return GatewayServiceResult(True, "service_install_dry_run", "systemd", path, tuple(commands), content)
|
||||||
|
|
||||||
|
_working_directory(options.start).mkdir(parents=True, exist_ok=True)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
for command_args in commands:
|
||||||
|
self._subprocess_run(list(command_args), check=True)
|
||||||
|
return GatewayServiceResult(True, "service_installed", "systemd", path, tuple(commands), content)
|
||||||
|
|
||||||
|
def _uninstall_systemd(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> GatewayServiceResult:
|
||||||
|
unit_name = _systemd_unit_name(name)
|
||||||
|
path = self.home / ".config" / "systemd" / "user" / unit_name
|
||||||
|
commands = (
|
||||||
|
("systemctl", "--user", "disable", "--now", unit_name),
|
||||||
|
("systemctl", "--user", "daemon-reload"),
|
||||||
|
)
|
||||||
|
if dry_run:
|
||||||
|
return GatewayServiceResult(True, "service_uninstall_dry_run", "systemd", path, commands)
|
||||||
|
|
||||||
|
self._run_best_effort(commands[0])
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
self._subprocess_run(list(commands[1]), check=True)
|
||||||
|
return GatewayServiceResult(True, "service_uninstalled", "systemd", path, commands)
|
||||||
|
|
||||||
|
def _install_launchd(
|
||||||
|
self,
|
||||||
|
options: GatewayServiceOptions,
|
||||||
|
*,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> GatewayServiceResult:
|
||||||
|
label = _launchd_label(options.name)
|
||||||
|
path = self.home / "Library" / "LaunchAgents" / f"{label}.plist"
|
||||||
|
log_stem = _safe_service_name(options.name)
|
||||||
|
stdout_path = self.home / ".nanobot" / "logs" / f"{log_stem}.launchd.log"
|
||||||
|
stderr_path = self.home / ".nanobot" / "logs" / f"{log_stem}.launchd.err.log"
|
||||||
|
payload = {
|
||||||
|
"Label": label,
|
||||||
|
"ProgramArguments": build_gateway_command(options.python_executable, options.start),
|
||||||
|
"WorkingDirectory": _working_directory_text(options.start),
|
||||||
|
"RunAtLoad": bool(options.start_now),
|
||||||
|
"KeepAlive": {"SuccessfulExit": False},
|
||||||
|
"StandardOutPath": str(stdout_path),
|
||||||
|
"StandardErrorPath": str(stderr_path),
|
||||||
|
}
|
||||||
|
content = plistlib.dumps(payload, sort_keys=False).decode("utf-8")
|
||||||
|
domain = _launchd_domain()
|
||||||
|
commands: list[tuple[str, ...]] = []
|
||||||
|
if options.enable or options.start_now:
|
||||||
|
commands.append(("launchctl", "bootstrap", domain, str(path)))
|
||||||
|
if options.enable:
|
||||||
|
commands.append(("launchctl", "enable", f"{domain}/{label}"))
|
||||||
|
if options.start_now:
|
||||||
|
commands.append(("launchctl", "kickstart", "-k", f"{domain}/{label}"))
|
||||||
|
if dry_run:
|
||||||
|
return GatewayServiceResult(True, "service_install_dry_run", "launchd", path, tuple(commands), content)
|
||||||
|
|
||||||
|
_working_directory(options.start).mkdir(parents=True, exist_ok=True)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
stdout_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
if options.enable or options.start_now:
|
||||||
|
self._run_best_effort(("launchctl", "bootout", domain, str(path)))
|
||||||
|
for command_args in commands:
|
||||||
|
self._subprocess_run(list(command_args), check=True)
|
||||||
|
return GatewayServiceResult(True, "service_installed", "launchd", path, tuple(commands), content)
|
||||||
|
|
||||||
|
def _uninstall_launchd(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
dry_run: bool,
|
||||||
|
) -> GatewayServiceResult:
|
||||||
|
label = _launchd_label(name)
|
||||||
|
path = self.home / "Library" / "LaunchAgents" / f"{label}.plist"
|
||||||
|
domain = _launchd_domain()
|
||||||
|
commands = (
|
||||||
|
("launchctl", "bootout", domain, str(path)),
|
||||||
|
("launchctl", "disable", f"{domain}/{label}"),
|
||||||
|
)
|
||||||
|
if dry_run:
|
||||||
|
return GatewayServiceResult(True, "service_uninstall_dry_run", "launchd", path, commands)
|
||||||
|
|
||||||
|
for command_args in commands:
|
||||||
|
self._run_best_effort(command_args)
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
return GatewayServiceResult(True, "service_uninstalled", "launchd", path, commands)
|
||||||
|
|
||||||
|
def _resolve_manager(self, manager: ServiceManagerKind) -> str:
|
||||||
|
if manager != "auto":
|
||||||
|
return manager
|
||||||
|
if self.platform_name == "Darwin":
|
||||||
|
return "launchd"
|
||||||
|
if self.platform_name == "Linux":
|
||||||
|
return "systemd"
|
||||||
|
return self.platform_name.lower()
|
||||||
|
|
||||||
|
def _run_best_effort(self, command_args: tuple[str, ...]) -> None:
|
||||||
|
self._subprocess_run(list(command_args), check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
|
||||||
|
|
||||||
|
def _platform_name() -> str:
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
return "Darwin"
|
||||||
|
if sys.platform.startswith("linux"):
|
||||||
|
return "Linux"
|
||||||
|
if sys.platform.startswith("win"):
|
||||||
|
return "Windows"
|
||||||
|
return sys.platform
|
||||||
|
|
||||||
|
|
||||||
|
def _working_directory(options: GatewayStartOptions) -> Path:
|
||||||
|
if options.workspace:
|
||||||
|
return Path(options.workspace).expanduser()
|
||||||
|
return Path.home()
|
||||||
|
|
||||||
|
|
||||||
|
def _working_directory_text(options: GatewayStartOptions) -> str:
|
||||||
|
if options.workspace:
|
||||||
|
return os.path.expanduser(options.workspace)
|
||||||
|
return str(Path.home())
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_unit_name(name: str) -> str:
|
||||||
|
stem = _safe_service_name(name)
|
||||||
|
return stem if stem.endswith(".service") else f"{stem}.service"
|
||||||
|
|
||||||
|
|
||||||
|
def _launchd_label(name: str) -> str:
|
||||||
|
if name.startswith("ai.nanobot."):
|
||||||
|
return name
|
||||||
|
suffix = _safe_service_name(name).removeprefix("nanobot-").replace("-", ".")
|
||||||
|
return f"ai.nanobot.{suffix}"
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_service_name(name: str) -> str:
|
||||||
|
value = name.strip().lower()
|
||||||
|
value = re.sub(r"[^a-z0-9_.-]+", "-", value)
|
||||||
|
value = value.strip(".-")
|
||||||
|
return value or "nanobot-gateway"
|
||||||
|
|
||||||
|
|
||||||
|
def _launchd_domain() -> str:
|
||||||
|
getuid = getattr(os, "getuid", None)
|
||||||
|
if getuid is None:
|
||||||
|
return "gui/current"
|
||||||
|
return f"gui/{getuid()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_unit_content(
|
||||||
|
*,
|
||||||
|
description: str,
|
||||||
|
command: list[str],
|
||||||
|
working_directory: str,
|
||||||
|
) -> str:
|
||||||
|
quoted_command = " ".join(_systemd_quote(part) for part in command)
|
||||||
|
return "\n".join(
|
||||||
|
[
|
||||||
|
"[Unit]",
|
||||||
|
f"Description={description}",
|
||||||
|
"After=network-online.target",
|
||||||
|
"Wants=network-online.target",
|
||||||
|
"",
|
||||||
|
"[Service]",
|
||||||
|
"Type=simple",
|
||||||
|
f"WorkingDirectory={_systemd_quote(str(working_directory))}",
|
||||||
|
f"ExecStart={quoted_command}",
|
||||||
|
"Restart=always",
|
||||||
|
"RestartSec=10",
|
||||||
|
"Environment=PYTHONUNBUFFERED=1",
|
||||||
|
"NoNewPrivileges=yes",
|
||||||
|
"",
|
||||||
|
"[Install]",
|
||||||
|
"WantedBy=default.target",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_quote(value: str) -> str:
|
||||||
|
if value and not re.search(r"\s|['\"\\]", value):
|
||||||
|
return value
|
||||||
|
return '"' + value.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||||
@@ -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