diff --git a/nanobot/cli/gateway.py b/nanobot/cli/gateway.py index 20e82d68f..ba224038c 100644 --- a/nanobot/cli/gateway.py +++ b/nanobot/cli/gateway.py @@ -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") diff --git a/nanobot/cli/gateway_runtime.py b/nanobot/cli/gateway_runtime.py index 78ac507b8..4d989dfb0 100644 --- a/nanobot/cli/gateway_runtime.py +++ b/nanobot/cli/gateway_runtime.py @@ -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): diff --git a/nanobot/cli/tui_launcher.py b/nanobot/cli/tui_launcher.py index 697aa943c..912212ea1 100644 --- a/nanobot/cli/tui_launcher.py +++ b/nanobot/cli/tui_launcher.py @@ -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( diff --git a/nanobot/cli/webui.py b/nanobot/cli/webui.py index bf4cd95ab..388e9ec00 100644 --- a/nanobot/cli/webui.py +++ b/nanobot/cli/webui.py @@ -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.""" diff --git a/nanobot/gateway/__init__.py b/nanobot/gateway/__init__.py index f66ef4b4d..f692d6e4e 100644 --- a/nanobot/gateway/__init__.py +++ b/nanobot/gateway/__init__.py @@ -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", diff --git a/nanobot/gateway/runtime.py b/nanobot/gateway/runtime.py index 528d49da7..703d59662 100644 --- a/nanobot/gateway/runtime.py +++ b/nanobot/gateway/runtime.py @@ -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.""" diff --git a/tests/cli/test_gateway_commands.py b/tests/cli/test_gateway_commands.py index c651eca58..9e5f7ea41 100644 --- a/tests/cli/test_gateway_commands.py +++ b/tests/cli/test_gateway_commands.py @@ -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 diff --git a/tests/gateway/test_runtime.py b/tests/gateway/test_runtime.py index 127d0e63e..d06b34bd1 100644 --- a/tests/gateway/test_runtime.py +++ b/tests/gateway/test_runtime.py @@ -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] = []