fix(gateway): preserve shared runtime identity

This commit is contained in:
Xubin Ren
2026-08-17 20:56:10 +08:00
parent c65127f791
commit c671acd6a8
8 changed files with 163 additions and 73 deletions
+25 -17
View File
@@ -14,8 +14,8 @@ from rich.console import Console
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.gateway import ( from nanobot.gateway import (
GatewayInstance,
GatewayRuntime, GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions, GatewayStartOptions,
GatewayStatus, GatewayStatus,
) )
@@ -78,19 +78,21 @@ def create_gateway_app(
filter=lambda record: record["extra"].setdefault("channel", "-") or True, 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): def runtime_for_instance(*, workspace: str | None = None, config: str | None = None):
if runtime_factory is not None: if runtime_factory is not None:
return runtime_factory(workspace=workspace, config=config) return runtime_factory(workspace=workspace, config=config)
resolved_config = _resolved_config_selector(config) instance = instance_for_selectors(workspace=workspace, config=config)
config_path = str(resolved_config) return GatewayRuntime(paths=instance.paths)
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,
)
)
def service_installer(): def service_installer():
return service_factory() if service_factory is not None else GatewayServiceInstaller() return service_factory() if service_factory is not None else GatewayServiceInstaller()
@@ -109,13 +111,12 @@ def create_gateway_app(
loaded_config: Config | None = None, loaded_config: Config | None = None,
) -> GatewayStartOptions: ) -> GatewayStartOptions:
cfg = loaded_config or load_runtime_config(config, workspace) cfg = loaded_config or load_runtime_config(config, workspace)
resolved_config = str(_resolved_config_selector(config)) if config else None return instance_for_selectors(
resolved_workspace = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None workspace=workspace,
return GatewayStartOptions( config=config,
).start_options(
port=port if port is not None else cfg.gateway.port, port=port if port is not None else cfg.gateway.port,
verbose=verbose, verbose=verbose,
workspace=resolved_workspace,
config_path=resolved_config,
) )
def print_status(status: GatewayStatus) -> None: def print_status(status: GatewayStatus) -> None:
@@ -235,17 +236,24 @@ def create_gateway_app(
configure_logging(verbose) configure_logging(verbose)
cfg = load_runtime_config(config, workspace) cfg = load_runtime_config(config, workspace)
instance = instance_for_selectors(workspace=workspace, config=config)
unconfigured_provider_error = None unconfigured_provider_error = None
if validate_startup_config is not None: if validate_startup_config is not None:
unconfigured_provider_error = validate_startup_config(cfg) unconfigured_provider_error = validate_startup_config(cfg)
if unconfigured_provider_error is None: 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: else:
run_gateway( run_gateway(
cfg, cfg,
port=port, port=port,
webui_bundle_mode=interactive_build_mode(), webui_bundle_mode=interactive_build_mode(),
unconfigured_provider_error=unconfigured_provider_error, unconfigured_provider_error=unconfigured_provider_error,
gateway_instance=instance,
) )
@gateway_app.command("status") @gateway_app.command("status")
+7 -18
View File
@@ -32,6 +32,7 @@ from nanobot.cli.webui_support import (
) )
from nanobot.config.paths import is_default_workspace from nanobot.config.paths import is_default_workspace
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.gateway.runtime import GatewayInstance
from nanobot.security.network import is_loopback_host from nanobot.security.network import is_loopback_host
from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata from nanobot.session.keys import UNIFIED_SESSION_KEY, last_channel_from_metadata
from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt from nanobot.utils.evaluator import evaluate_response, resolve_evaluator_prompt
@@ -298,6 +299,7 @@ def _run_gateway(
health_server_enabled: bool = True, health_server_enabled: bool = True,
unconfigured_provider_error: str | None = None, unconfigured_provider_error: str | None = None,
webui_dev_server: WebUIDevServer | None = None, webui_dev_server: WebUIDevServer | None = None,
gateway_instance: GatewayInstance | None = None,
) -> None: ) -> None:
"""Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up.""" """Shared gateway runtime; ``open_browser_url`` opens a tab once channels are up."""
from nanobot.agent.model_presets import load_model_preset_catalog from nanobot.agent.model_presets import load_model_preset_catalog
@@ -392,28 +394,15 @@ def _run_gateway(
from nanobot.gateway.runtime import ( from nanobot.gateway.runtime import (
GatewayClientLease, GatewayClientLease,
GatewayRuntime, GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
monitor_gateway_clients, monitor_gateway_clients,
) )
config_path = str(get_config_path().resolve(strict=False)) instance = gateway_instance or GatewayInstance.resolve(
gateway_workspace = ( config_path=get_config_path(),
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,
) )
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. # Preserve existing single-workspace installs, but keep custom workspaces clean.
if is_default_workspace(config.workspace_path): if is_default_workspace(config.workspace_path):
+6 -18
View File
@@ -256,24 +256,16 @@ def _ensure_gateway(
) -> _GatewayHandle: ) -> _GatewayHandle:
from nanobot.gateway import ( from nanobot.gateway import (
GatewayClientLease, GatewayClientLease,
GatewayInstance,
GatewayRuntime, GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
) )
base_url = _webui_browser_url(config).split("/#/", 1)[0].rstrip("/") base_url = _webui_browser_url(config).split("/#/", 1)[0].rstrip("/")
workspace_override_path = ( instance = GatewayInstance.resolve(
str(Path(workspace_override).expanduser().resolve(strict=False)) config_path=config_path,
if workspace_override workspace=workspace_override,
else None
)
runtime = GatewayRuntime(
paths=GatewayRuntimePaths.for_instance(
data_dir=config_path.parent,
workspace=workspace_override_path,
config_path=str(config_path),
)
) )
runtime = GatewayRuntime(paths=instance.paths)
lease = GatewayClientLease(runtime, kind="tui") lease = GatewayClientLease(runtime, kind="tui")
lease.acquire() lease.acquire()
try: try:
@@ -294,11 +286,7 @@ def _ensure_gateway(
) )
result = lease.ensure_on_demand_gateway( result = lease.ensure_on_demand_gateway(
GatewayStartOptions( instance.start_options(port=config.gateway.port)
port=config.gateway.port,
workspace=workspace_override_path,
config_path=str(config_path),
)
) )
if not result.ok and result.message != "gateway_already_running": if not result.ok and result.message != "gateway_already_running":
raise TuiUnavailableError( raise TuiUnavailableError(
+6 -15
View File
@@ -101,9 +101,8 @@ def webui(
from nanobot.config.loader import resolve_config_env_vars, save_config from nanobot.config.loader import resolve_config_env_vars, save_config
from nanobot.gateway import ( from nanobot.gateway import (
GatewayClientLease, GatewayClientLease,
GatewayInstance,
GatewayRuntime, GatewayRuntime,
GatewayRuntimePaths,
GatewayStartOptions,
) )
cli_terminal._ensure_interactive_tty_mode() cli_terminal._ensure_interactive_tty_mode()
@@ -223,20 +222,12 @@ def webui(
mode="skip" if dev else webui_bundle_mode, mode="skip" if dev else webui_bundle_mode,
) )
config_arg = str(config_path) instance = GatewayInstance.resolve(
workspace_arg = str(Path(workspace).expanduser().resolve(strict=False)) if workspace else None config_path=config_path,
runtime = GatewayRuntime( workspace=workspace,
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,
) )
runtime = GatewayRuntime(paths=instance.paths)
start_options = instance.start_options(port=effective_gateway_port)
def ensure_shared_gateway(*, client_lease: GatewayClientLease) -> None: def ensure_shared_gateway(*, client_lease: GatewayClientLease) -> None:
"""Start or refresh the one managed gateway shared by local clients.""" """Start or refresh the one managed gateway shared by local clients."""
+2
View File
@@ -2,6 +2,7 @@
from nanobot.gateway.runtime import ( from nanobot.gateway.runtime import (
GatewayClientLease, GatewayClientLease,
GatewayInstance,
GatewayRuntime, GatewayRuntime,
GatewayRuntimePaths, GatewayRuntimePaths,
GatewayStartOptions, GatewayStartOptions,
@@ -12,6 +13,7 @@ from nanobot.gateway.runtime import (
__all__ = [ __all__ = [
"GatewayClientLease", "GatewayClientLease",
"GatewayInstance",
"GatewayRuntime", "GatewayRuntime",
"GatewayRuntimePaths", "GatewayRuntimePaths",
"GatewayStartOptions", "GatewayStartOptions",
+54
View File
@@ -37,6 +37,10 @@ GatewayLaunchMode = Literal["foreground", "background", "unknown"]
GatewayLifetime = Literal["explicit", "on_demand"] GatewayLifetime = Literal["explicit", "on_demand"]
def _default_config_path() -> Path:
return (Path.home() / ".nanobot" / "config.json").resolve(strict=False)
@dataclass(frozen=True) @dataclass(frozen=True)
class GatewayStatus(ProcessStatus): class GatewayStatus(ProcessStatus):
"""Observable lifecycle state for one shared local gateway.""" """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]): class GatewayRuntime(ManagedProcessRuntime[ProcessStartOptions]):
"""Manage a background ``nanobot gateway`` process.""" """Manage a background ``nanobot gateway`` process."""
+34 -5
View File
@@ -8,7 +8,13 @@ from typer.testing import CliRunner
from nanobot.cli.gateway import _resolved_config_selector, create_gateway_app from nanobot.cli.gateway import _resolved_config_selector, create_gateway_app
from nanobot.config.schema import Config 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 from nanobot.gateway.service import GatewayServiceOptions, GatewayServiceResult
runner = CliRunner() runner = CliRunner()
@@ -111,7 +117,9 @@ def _test_app(
app = typer.Typer() app = typer.Typer()
fake_runtime = FakeRuntime(tmp_path) fake_runtime = FakeRuntime(tmp_path)
fake_service = FakeServiceInstaller(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]] = [] prepare_calls: list[tuple[Config, str]] = []
def load_runtime_config(_config_path: str | None, _workspace: str | None) -> Config: def load_runtime_config(_config_path: str | None, _workspace: str | None) -> Config:
@@ -123,9 +131,10 @@ def _test_app(
port: int | None = None, port: int | None = None,
webui_bundle_mode: str | None = None, webui_bundle_mode: str | None = None,
unconfigured_provider_error: str | None = None, unconfigured_provider_error: str | None = None,
gateway_instance: GatewayInstance | None = None,
) -> None: ) -> None:
run_calls.append( 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: 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 len(calls) == 1
assert calls[0][1] == 18791 assert calls[0][1] == 18791
assert calls[0][2] == "warn" 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): 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 result.exit_code == 0
assert "Gateway started in the background" in result.stdout 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")] assert prepare_calls == [(config, "warn")]
@@ -344,7 +371,9 @@ def test_gateway_restart_starts_background_runtime(tmp_path):
assert result.exit_code == 0 assert result.exit_code == 0
assert "Gateway restarted in the background" in result.stdout assert "Gateway restarted in the background" in result.stdout
assert fake_runtime.stop_timeout == 9 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 prepare_calls == [(config, "warn")]
assert json.loads(lease_state.read_text(encoding="utf-8"))["auto_stop"] is True assert json.loads(lease_state.read_text(encoding="utf-8"))["auto_stop"] is True
+29
View File
@@ -12,6 +12,7 @@ import pytest
from nanobot.gateway import ( from nanobot.gateway import (
GatewayClientLease, GatewayClientLease,
GatewayInstance,
GatewayRuntime, GatewayRuntime,
GatewayRuntimePaths, GatewayRuntimePaths,
GatewayStartOptions, 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 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): def test_start_background_writes_state_and_child_command(tmp_path, monkeypatch):
calls: list[dict] = [] calls: list[dict] = []