mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 00:03:01 +03:00
fix(gateway): preserve shared runtime identity
This commit is contained in:
+25
-17
@@ -14,8 +14,8 @@ from rich.console import Console
|
||||
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.gateway import (
|
||||
GatewayInstance,
|
||||
GatewayRuntime,
|
||||
GatewayRuntimePaths,
|
||||
GatewayStartOptions,
|
||||
GatewayStatus,
|
||||
)
|
||||
@@ -78,19 +78,21 @@ def create_gateway_app(
|
||||
filter=lambda record: record["extra"].setdefault("channel", "-") or True,
|
||||
)
|
||||
|
||||
def instance_for_selectors(
|
||||
*,
|
||||
workspace: str | None = None,
|
||||
config: str | None = None,
|
||||
) -> GatewayInstance:
|
||||
return GatewayInstance.resolve(
|
||||
config_path=_resolved_config_selector(config),
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
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)
|
||||
resolved_config = _resolved_config_selector(config)
|
||||
config_path = str(resolved_config)
|
||||
workspace_path = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
|
||||
return GatewayRuntime(
|
||||
paths=GatewayRuntimePaths.for_instance(
|
||||
data_dir=resolved_config.parent,
|
||||
workspace=workspace_path,
|
||||
config_path=config_path,
|
||||
)
|
||||
)
|
||||
instance = instance_for_selectors(workspace=workspace, config=config)
|
||||
return GatewayRuntime(paths=instance.paths)
|
||||
|
||||
def service_installer():
|
||||
return service_factory() if service_factory is not None else GatewayServiceInstaller()
|
||||
@@ -109,13 +111,12 @@ def create_gateway_app(
|
||||
loaded_config: Config | None = None,
|
||||
) -> GatewayStartOptions:
|
||||
cfg = loaded_config or load_runtime_config(config, workspace)
|
||||
resolved_config = str(_resolved_config_selector(config)) if config else None
|
||||
resolved_workspace = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
|
||||
return GatewayStartOptions(
|
||||
return instance_for_selectors(
|
||||
workspace=workspace,
|
||||
config=config,
|
||||
).start_options(
|
||||
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:
|
||||
@@ -235,17 +236,24 @@ def create_gateway_app(
|
||||
|
||||
configure_logging(verbose)
|
||||
cfg = load_runtime_config(config, workspace)
|
||||
instance = instance_for_selectors(workspace=workspace, config=config)
|
||||
unconfigured_provider_error = None
|
||||
if validate_startup_config is not None:
|
||||
unconfigured_provider_error = validate_startup_config(cfg)
|
||||
if unconfigured_provider_error is None:
|
||||
run_gateway(cfg, port=port, webui_bundle_mode=interactive_build_mode())
|
||||
run_gateway(
|
||||
cfg,
|
||||
port=port,
|
||||
webui_bundle_mode=interactive_build_mode(),
|
||||
gateway_instance=instance,
|
||||
)
|
||||
else:
|
||||
run_gateway(
|
||||
cfg,
|
||||
port=port,
|
||||
webui_bundle_mode=interactive_build_mode(),
|
||||
unconfigured_provider_error=unconfigured_provider_error,
|
||||
gateway_instance=instance,
|
||||
)
|
||||
|
||||
@gateway_app.command("status")
|
||||
|
||||
@@ -32,6 +32,7 @@ from nanobot.cli.webui_support import (
|
||||
)
|
||||
from nanobot.config.paths import is_default_workspace
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.gateway.runtime import GatewayInstance
|
||||
from nanobot.security.network import is_loopback_host
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata
|
||||
from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt
|
||||
@@ -298,6 +299,7 @@ def _run_gateway(
|
||||
health_server_enabled: bool = True,
|
||||
unconfigured_provider_error: str | None = None,
|
||||
webui_dev_server: WebUIDevServer | None = None,
|
||||
gateway_instance: GatewayInstance | None = None,
|
||||
) -> None:
|
||||
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
|
||||
from nanobot.agent.model_presets import load_model_preset_catalog
|
||||
@@ -392,28 +394,15 @@ def _run_gateway(
|
||||
from nanobot.gateway.runtime import (
|
||||
GatewayClientLease,
|
||||
GatewayRuntime,
|
||||
GatewayRuntimePaths,
|
||||
GatewayStartOptions,
|
||||
monitor_gateway_clients,
|
||||
)
|
||||
|
||||
config_path = str(get_config_path().resolve(strict=False))
|
||||
gateway_workspace = (
|
||||
str(config.workspace_path)
|
||||
if not is_default_workspace(config.workspace_path)
|
||||
else None
|
||||
)
|
||||
gateway_runtime = GatewayRuntime(
|
||||
paths=GatewayRuntimePaths.for_instance(
|
||||
workspace=gateway_workspace,
|
||||
config_path=config_path,
|
||||
)
|
||||
)
|
||||
gateway_start_options = GatewayStartOptions(
|
||||
port=port,
|
||||
workspace=gateway_workspace,
|
||||
config_path=config_path,
|
||||
instance = gateway_instance or GatewayInstance.resolve(
|
||||
config_path=get_config_path(),
|
||||
)
|
||||
config_path = str(instance.config_path)
|
||||
gateway_runtime = GatewayRuntime(paths=instance.paths)
|
||||
gateway_start_options = instance.start_options(port=port)
|
||||
|
||||
# Preserve existing single-workspace installs, but keep custom workspaces clean.
|
||||
if is_default_workspace(config.workspace_path):
|
||||
|
||||
@@ -256,24 +256,16 @@ def _ensure_gateway(
|
||||
) -> _GatewayHandle:
|
||||
from nanobot.gateway import (
|
||||
GatewayClientLease,
|
||||
GatewayInstance,
|
||||
GatewayRuntime,
|
||||
GatewayRuntimePaths,
|
||||
GatewayStartOptions,
|
||||
)
|
||||
|
||||
base_url = _webui_browser_url(config).split("/#/", 1)[0].rstrip("/")
|
||||
workspace_override_path = (
|
||||
str(Path(workspace_override).expanduser().resolve(strict=False))
|
||||
if workspace_override
|
||||
else None
|
||||
)
|
||||
runtime = GatewayRuntime(
|
||||
paths=GatewayRuntimePaths.for_instance(
|
||||
data_dir=config_path.parent,
|
||||
workspace=workspace_override_path,
|
||||
config_path=str(config_path),
|
||||
)
|
||||
instance = GatewayInstance.resolve(
|
||||
config_path=config_path,
|
||||
workspace=workspace_override,
|
||||
)
|
||||
runtime = GatewayRuntime(paths=instance.paths)
|
||||
lease = GatewayClientLease(runtime, kind="tui")
|
||||
lease.acquire()
|
||||
try:
|
||||
@@ -294,11 +286,7 @@ def _ensure_gateway(
|
||||
)
|
||||
|
||||
result = lease.ensure_on_demand_gateway(
|
||||
GatewayStartOptions(
|
||||
port=config.gateway.port,
|
||||
workspace=workspace_override_path,
|
||||
config_path=str(config_path),
|
||||
)
|
||||
instance.start_options(port=config.gateway.port)
|
||||
)
|
||||
if not result.ok and result.message != "gateway_already_running":
|
||||
raise TuiUnavailableError(
|
||||
|
||||
+6
-15
@@ -101,9 +101,8 @@ def webui(
|
||||
from nanobot.config.loader import resolve_config_env_vars, save_config
|
||||
from nanobot.gateway import (
|
||||
GatewayClientLease,
|
||||
GatewayInstance,
|
||||
GatewayRuntime,
|
||||
GatewayRuntimePaths,
|
||||
GatewayStartOptions,
|
||||
)
|
||||
|
||||
cli_terminal._ensure_interactive_tty_mode()
|
||||
@@ -223,20 +222,12 @@ def webui(
|
||||
mode="skip" if dev else webui_bundle_mode,
|
||||
)
|
||||
|
||||
config_arg = str(config_path)
|
||||
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
|
||||
runtime = GatewayRuntime(
|
||||
paths=GatewayRuntimePaths.for_instance(
|
||||
data_dir=config_path.parent,
|
||||
workspace=workspace_arg,
|
||||
config_path=config_arg,
|
||||
)
|
||||
)
|
||||
start_options = GatewayStartOptions(
|
||||
port=effective_gateway_port,
|
||||
workspace=workspace_arg,
|
||||
config_path=config_arg,
|
||||
instance = GatewayInstance.resolve(
|
||||
config_path=config_path,
|
||||
workspace=workspace,
|
||||
)
|
||||
runtime = GatewayRuntime(paths=instance.paths)
|
||||
start_options = instance.start_options(port=effective_gateway_port)
|
||||
|
||||
def ensure_shared_gateway(*, client_lease: GatewayClientLease) -> None:
|
||||
"""Start or refresh the one managed gateway shared by local clients."""
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from nanobot.gateway.runtime import (
|
||||
GatewayClientLease,
|
||||
GatewayInstance,
|
||||
GatewayRuntime,
|
||||
GatewayRuntimePaths,
|
||||
GatewayStartOptions,
|
||||
@@ -12,6 +13,7 @@ from nanobot.gateway.runtime import (
|
||||
|
||||
__all__ = [
|
||||
"GatewayClientLease",
|
||||
"GatewayInstance",
|
||||
"GatewayRuntime",
|
||||
"GatewayRuntimePaths",
|
||||
"GatewayStartOptions",
|
||||
|
||||
@@ -37,6 +37,10 @@ GatewayLaunchMode = Literal["foreground", "background", "unknown"]
|
||||
GatewayLifetime = Literal["explicit", "on_demand"]
|
||||
|
||||
|
||||
def _default_config_path() -> Path:
|
||||
return (Path.home() / ".nanobot" / "config.json").resolve(strict=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayStatus(ProcessStatus):
|
||||
"""Observable lifecycle state for one shared local gateway."""
|
||||
@@ -107,6 +111,56 @@ class GatewayRuntimePaths(ProcessRuntimePaths):
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GatewayInstance:
|
||||
"""One stable local gateway identity and its child-process selectors."""
|
||||
|
||||
config_path: Path
|
||||
workspace: str | None
|
||||
paths: GatewayRuntimePaths
|
||||
|
||||
@classmethod
|
||||
def resolve(
|
||||
cls,
|
||||
*,
|
||||
config_path: str | Path,
|
||||
workspace: str | None = None,
|
||||
) -> "GatewayInstance":
|
||||
resolved_config = Path(config_path).expanduser().resolve(strict=False)
|
||||
resolved_workspace = (
|
||||
str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None
|
||||
)
|
||||
# The released default instance used gateway.json. Keep that identity stable
|
||||
# across upgrades while still namespacing explicit configs and workspaces.
|
||||
config_selector = (
|
||||
None if resolved_config == _default_config_path() else str(resolved_config)
|
||||
)
|
||||
return cls(
|
||||
config_path=resolved_config,
|
||||
workspace=resolved_workspace,
|
||||
paths=GatewayRuntimePaths.for_instance(
|
||||
data_dir=resolved_config.parent,
|
||||
workspace=resolved_workspace,
|
||||
config_path=config_selector,
|
||||
),
|
||||
)
|
||||
|
||||
def start_options(
|
||||
self,
|
||||
*,
|
||||
port: int,
|
||||
verbose: bool = False,
|
||||
) -> GatewayStartOptions:
|
||||
return GatewayStartOptions(
|
||||
port=port,
|
||||
verbose=verbose,
|
||||
workspace=self.workspace,
|
||||
config_path=(
|
||||
None if self.config_path == _default_config_path() else str(self.config_path)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
|
||||
"""Manage a background ``nanobot gateway`` process."""
|
||||
|
||||
|
||||
@@ -8,7 +8,13 @@ from typer.testing import CliRunner
|
||||
|
||||
from nanobot.cli.gateway import _resolved_config_selector, create_gateway_app
|
||||
from nanobot.config.schema import Config
|
||||
from nanobot.gateway import GatewayRuntimePaths, GatewayStartOptions, GatewayStatus, RuntimeResult
|
||||
from nanobot.gateway import (
|
||||
GatewayInstance,
|
||||
GatewayRuntimePaths,
|
||||
GatewayStartOptions,
|
||||
GatewayStatus,
|
||||
RuntimeResult,
|
||||
)
|
||||
from nanobot.gateway.service import GatewayServiceOptions, GatewayServiceResult
|
||||
|
||||
runner = CliRunner()
|
||||
@@ -111,7 +117,9 @@ def _test_app(
|
||||
app = typer.Typer()
|
||||
fake_runtime = FakeRuntime(tmp_path)
|
||||
fake_service = FakeServiceInstaller(tmp_path)
|
||||
run_calls: list[tuple[Config, int | None, str | None, str | None]] = []
|
||||
run_calls: list[
|
||||
tuple[Config, int | None, str | None, str | None, GatewayInstance | None]
|
||||
] = []
|
||||
prepare_calls: list[tuple[Config, str]] = []
|
||||
|
||||
def load_runtime_config(_config_path: str | None, _workspace: str | None) -> Config:
|
||||
@@ -123,9 +131,10 @@ def _test_app(
|
||||
port: int | None = None,
|
||||
webui_bundle_mode: str | None = None,
|
||||
unconfigured_provider_error: str | None = None,
|
||||
gateway_instance: GatewayInstance | None = None,
|
||||
) -> None:
|
||||
run_calls.append(
|
||||
(config, port, webui_bundle_mode, unconfigured_provider_error)
|
||||
(config, port, webui_bundle_mode, unconfigured_provider_error, gateway_instance)
|
||||
)
|
||||
|
||||
def prepare_webui_bundle(config: Config, mode: str) -> None:
|
||||
@@ -162,6 +171,22 @@ def test_gateway_default_still_runs_foreground(tmp_path):
|
||||
assert len(calls) == 1
|
||||
assert calls[0][1] == 18791
|
||||
assert calls[0][2] == "warn"
|
||||
assert calls[0][4] == GatewayInstance.resolve(
|
||||
config_path=_resolved_config_selector(None)
|
||||
)
|
||||
|
||||
|
||||
def test_config_workspace_does_not_split_the_foreground_instance(tmp_path: Path) -> None:
|
||||
config = Config()
|
||||
config.agents.defaults.workspace = str(tmp_path / "configured-workspace")
|
||||
app, _runtime, _service, calls, _prepare_calls = _test_app(tmp_path, config=config)
|
||||
|
||||
result = runner.invoke(app, ["gateway"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert calls[0][4] == GatewayInstance.resolve(
|
||||
config_path=_resolved_config_selector(None)
|
||||
)
|
||||
|
||||
|
||||
def test_gateway_foreground_passes_recoverable_provider_error_to_runner(tmp_path):
|
||||
@@ -213,7 +238,9 @@ def test_gateway_background_starts_detached_runtime(tmp_path):
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Gateway started in the background" in result.stdout
|
||||
assert fake_runtime.started_options == GatewayStartOptions(port=18792)
|
||||
assert fake_runtime.started_options == GatewayInstance.resolve(
|
||||
config_path=_resolved_config_selector(None)
|
||||
).start_options(port=18792)
|
||||
assert prepare_calls == [(config, "warn")]
|
||||
|
||||
|
||||
@@ -344,7 +371,9 @@ def test_gateway_restart_starts_background_runtime(tmp_path):
|
||||
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)
|
||||
assert fake_runtime.restarted_options == GatewayInstance.resolve(
|
||||
config_path=_resolved_config_selector(None)
|
||||
).start_options(port=18793, verbose=True)
|
||||
assert prepare_calls == [(config, "warn")]
|
||||
assert json.loads(lease_state.read_text(encoding="utf-8"))["auto_stop"] is True
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
|
||||
from nanobot.gateway import (
|
||||
GatewayClientLease,
|
||||
GatewayInstance,
|
||||
GatewayRuntime,
|
||||
GatewayRuntimePaths,
|
||||
GatewayStartOptions,
|
||||
@@ -58,6 +59,34 @@ def test_paths_use_stable_instance_suffix_for_custom_selectors(tmp_path):
|
||||
assert first_paths.log_path != second_paths.log_path
|
||||
|
||||
|
||||
def test_default_instance_preserves_released_gateway_paths() -> None:
|
||||
config_path = Path.home() / ".nanobot" / "config.json"
|
||||
|
||||
instance = GatewayInstance.resolve(config_path=config_path)
|
||||
|
||||
assert instance.paths.state_path == config_path.parent / "run" / "gateway.json"
|
||||
assert instance.paths.log_path == config_path.parent / "logs" / "gateway.log"
|
||||
assert instance.start_options(port=18790) == GatewayStartOptions(port=18790)
|
||||
|
||||
|
||||
def test_custom_instance_round_trips_the_same_child_selectors(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "instance" / "config.json"
|
||||
workspace = tmp_path / "workspace"
|
||||
parent = GatewayInstance.resolve(
|
||||
config_path=config_path,
|
||||
workspace=str(workspace),
|
||||
)
|
||||
options = parent.start_options(port=18790)
|
||||
|
||||
child = GatewayInstance.resolve(
|
||||
config_path=options.config_path or "",
|
||||
workspace=options.workspace,
|
||||
)
|
||||
|
||||
assert child == parent
|
||||
assert parent.paths.state_path.name.startswith("gateway.")
|
||||
|
||||
|
||||
def test_start_background_writes_state_and_child_command(tmp_path, monkeypatch):
|
||||
calls: list[dict] = []
|
||||
|
||||
|
||||
Reference in New Issue
Block a user