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.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")
+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.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):
+6 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+54
View File
@@ -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."""