from __future__ import annotations import logging from dataclasses import dataclass, field from enum import StrEnum from functools import lru_cache from .snapshot import is_sensitive_config_path logger = logging.getLogger(__name__) def _redact_path(path: str) -> str: """对敏感配置路径进行脱敏处理。""" if not is_sensitive_config_path(path): return path parts = path.rsplit(".", 1) if len(parts) == 2: return f"{parts[0]}." return "" class ReloadMode(StrEnum): HOT = "hot" RESTART = "restart" HYBRID = "hybrid" OFF = "off" class ReloadAction(StrEnum): RELOAD_HOOKS = "reload_hooks" RESTART_GMAIL_WATCHER = "restart_gmail_watcher" RESTART_CRON = "restart_cron" RESTART_HEARTBEAT = "restart_heartbeat" RESTART_HEALTH_MONITOR = "restart_health_monitor" RELOAD_PLUGINS = "reload_plugins" DISPOSE_MCP_RUNTIMES = "dispose_mcp_runtimes" RESTART_CHANNEL = "restart_channel" RESTART_GATEWAY = "restart_gateway" @classmethod def _missing_(cls, value: object) -> "ReloadAction | None": if isinstance(value, str) and value.startswith("restart_channel:"): return cls._create_dynamic(value) return None @classmethod def _create_dynamic(cls, value: str) -> "ReloadAction": obj = str.__new__(cls, value) obj._name_ = value obj._value_ = value return obj class ReloadRuleKind(StrEnum): RESTART = "restart" HOT = "hot" NONE = "none" @dataclass(frozen=True, slots=True) class ReloadRule: prefix: str kind: ReloadRuleKind actions: tuple[ReloadAction, ...] = () def matches(self, path: str) -> bool: return path == self.prefix or path.startswith(f"{self.prefix}.") BASE_RELOAD_RULES: tuple[ReloadRule, ...] = ( ReloadRule("gateway.remote", kind=ReloadRuleKind.NONE), ReloadRule("gateway.reload", kind=ReloadRuleKind.NONE), ReloadRule( "gateway.channel_health_check_minutes", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RESTART_HEALTH_MONITOR,), ), ReloadRule( "gateway.channel_stale_event_threshold_minutes", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RESTART_HEALTH_MONITOR,), ), ReloadRule( "gateway.channel_max_restarts_per_hour", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RESTART_HEALTH_MONITOR,), ), ReloadRule("diagnostics.stuck_session_warn_ms", kind=ReloadRuleKind.NONE), ReloadRule("diagnostics.stuck_session_abort_ms", kind=ReloadRuleKind.NONE), ReloadRule("hooks.gmail", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RESTART_GMAIL_WATCHER,)), ReloadRule("hooks", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RELOAD_HOOKS,)), ReloadRule("agents.defaults.heartbeat", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RESTART_HEARTBEAT,)), ReloadRule("agents.defaults.models", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RESTART_HEARTBEAT,)), ReloadRule("agents.defaults.model", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RESTART_HEARTBEAT,)), ReloadRule("models.pricing", kind=ReloadRuleKind.RESTART), ReloadRule("models", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RESTART_HEARTBEAT,)), ReloadRule("agents.list", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RESTART_HEARTBEAT,)), ReloadRule("agent.heartbeat", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RESTART_HEARTBEAT,)), ReloadRule("cron", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RESTART_CRON,)), ReloadRule("mcp", kind=ReloadRuleKind.HOT, actions=(ReloadAction.DISPOSE_MCP_RUNTIMES,)), ReloadRule("plugins.load", kind=ReloadRuleKind.RESTART), ReloadRule("plugins.installs", kind=ReloadRuleKind.RESTART), ) BASE_RELOAD_RULES_TAIL: tuple[ReloadRule, ...] = ( ReloadRule("meta", kind=ReloadRuleKind.NONE), ReloadRule("identity", kind=ReloadRuleKind.NONE), ReloadRule("wizard", kind=ReloadRuleKind.NONE), ReloadRule("logging", kind=ReloadRuleKind.NONE), ReloadRule("agents", kind=ReloadRuleKind.NONE), ReloadRule("tools", kind=ReloadRuleKind.NONE), ReloadRule("bindings", kind=ReloadRuleKind.NONE), ReloadRule("audio", kind=ReloadRuleKind.NONE), ReloadRule("agent", kind=ReloadRuleKind.NONE), ReloadRule("routing", kind=ReloadRuleKind.NONE), ReloadRule("messages", kind=ReloadRuleKind.NONE), ReloadRule("session", kind=ReloadRuleKind.NONE), ReloadRule("talk", kind=ReloadRuleKind.NONE), ReloadRule("skills", kind=ReloadRuleKind.NONE), ReloadRule("secrets", kind=ReloadRuleKind.NONE), ReloadRule( "plugins", kind=ReloadRuleKind.HOT, actions=(ReloadAction.RELOAD_PLUGINS, ReloadAction.DISPOSE_MCP_RUNTIMES), ), ReloadRule("ui", kind=ReloadRuleKind.NONE), ReloadRule("gateway", kind=ReloadRuleKind.RESTART), ReloadRule("discovery", kind=ReloadRuleKind.RESTART), ) @dataclass class GatewayReloadPlan: mode: ReloadMode = ReloadMode.HYBRID changed_paths: list[str] = field(default_factory=list) restart_gateway: bool = False restart_reasons: list[str] = field(default_factory=list) hot_reasons: list[str] = field(default_factory=list) reload_hooks: bool = False restart_gmail_watcher: bool = False restart_cron: bool = False restart_heartbeat: bool = False restart_health_monitor: bool = False reload_plugins: bool = False restart_channels: set[str] = field(default_factory=set) dispose_mcp_runtimes: bool = False noop_paths: list[str] = field(default_factory=list) @property def has_channel_restarts(self) -> bool: return bool(self.restart_channels) @property def requires_full_restart(self) -> bool: return self.restart_gateway @property def is_noop(self) -> bool: return ( not self.restart_gateway and not self.hot_reasons and not self.reload_hooks and not self.restart_gmail_watcher and not self.restart_cron and not self.restart_heartbeat and not self.restart_health_monitor and not self.reload_plugins and not self.dispose_mcp_runtimes and not self.restart_channels ) @property def summary(self) -> str: parts = [f"mode={self.mode.value}"] if self.restart_gateway: redacted_reasons = [_redact_path(p) for p in self.restart_reasons[:3]] parts.append(f"restart({', '.join(redacted_reasons)})") if self.hot_reasons: redacted_hot = [_redact_path(p) for p in self.hot_reasons[:5]] hot_preview = ", ".join(redacted_hot) parts.append(f"hot=[{hot_preview}]") flags = [] if self.reload_hooks: flags.append("reload_hooks") if self.restart_cron: flags.append("restart_cron") if self.restart_heartbeat: flags.append("restart_heartbeat") if self.restart_health_monitor: flags.append("restart_health_monitor") if self.reload_plugins: flags.append("reload_plugins") if self.dispose_mcp_runtimes: flags.append("dispose_mcp") if self.restart_channels: flags.append(f"channels={{{','.join(sorted(self.restart_channels))}}}") if self.noop_paths: flags.append(f"noop={len(self.noop_paths)}") if flags: parts.append(", ".join(flags)) return f"GatewayReloadPlan({'; '.join(parts)})" _plugin_rules: dict[str, tuple[ReloadRule, ...]] = {} def clear_plugin_reload_rules() -> None: """清除所有已注册的插件重载规则(主要用于测试)。""" _plugin_rules.clear() _build_merged_rules.cache_clear() @lru_cache(maxsize=1) def _build_merged_rules() -> tuple[ReloadRule, ...]: rules: list[ReloadRule] = list(BASE_RELOAD_RULES) for plugin_rules in _plugin_rules.values(): rules.extend(plugin_rules) rules.extend(BASE_RELOAD_RULES_TAIL) return tuple(rules) def register_plugin_reload_rules( channel_id: str, config_prefixes: list[str] | None = None, noop_prefixes: list[str] | None = None, ) -> None: rules: list[ReloadRule] = [] for prefix in config_prefixes or []: rules.append( ReloadRule( prefix, kind=ReloadRuleKind.HOT, actions=(ReloadAction(f"restart_channel:{channel_id}"),), ) ) for prefix in noop_prefixes or []: rules.append(ReloadRule(prefix, kind=ReloadRuleKind.NONE)) rules.append( ReloadRule( f"plugins.entries.{channel_id}", kind=ReloadRuleKind.HOT, actions=( ReloadAction.RELOAD_PLUGINS, ReloadAction.DISPOSE_MCP_RUNTIMES, ReloadAction(f"restart_channel:{channel_id}"), ), ), ) _plugin_rules[channel_id] = tuple(rules) _build_merged_rules.cache_clear() def unregister_plugin_reload_rules(channel_id: str) -> None: """注销指定插件的重载规则。""" if channel_id in _plugin_rules: del _plugin_rules[channel_id] _build_merged_rules.cache_clear() def _match_rule(path: str) -> ReloadRule | None: rules = _build_merged_rules() for rule in rules: if rule.matches(path): return rule return None def _resolve_action(action: ReloadAction, plan: GatewayReloadPlan) -> None: action_str = action.value if action_str.startswith("restart_channel:"): channel = action_str[len("restart_channel:"):] plan.restart_channels.add(channel) return match action: case ReloadAction.RELOAD_HOOKS: plan.reload_hooks = True case ReloadAction.RESTART_GMAIL_WATCHER: plan.restart_gmail_watcher = True case ReloadAction.RESTART_CRON: plan.restart_cron = True case ReloadAction.RESTART_HEARTBEAT: plan.restart_heartbeat = True case ReloadAction.RESTART_HEALTH_MONITOR: plan.restart_health_monitor = True case ReloadAction.RELOAD_PLUGINS: plan.reload_plugins = True case ReloadAction.DISPOSE_MCP_RUNTIMES: plan.dispose_mcp_runtimes = True case ReloadAction.RESTART_CHANNEL | ReloadAction.RESTART_GATEWAY: logger.warning("Unhandled reload action: %s", action.value) case _: logger.warning("Unknown reload action: %s", action.value) def build_gateway_reload_plan( changed_paths: list[str], mode: ReloadMode = ReloadMode.HYBRID, ) -> GatewayReloadPlan: if mode == ReloadMode.OFF: return GatewayReloadPlan(mode=mode, changed_paths=list(changed_paths)) plan = GatewayReloadPlan(mode=mode, changed_paths=list(changed_paths)) if mode == ReloadMode.RESTART: plan.restart_gateway = True plan.restart_reasons = list(changed_paths) return plan for path in changed_paths: rule = _match_rule(path) if rule is None: plan.restart_gateway = True plan.restart_reasons.append(path) continue if rule.kind == ReloadRuleKind.RESTART: plan.restart_gateway = True plan.restart_reasons.append(path) continue if rule.kind == ReloadRuleKind.NONE: plan.noop_paths.append(path) continue plan.hot_reasons.append(path) for action in rule.actions: _resolve_action(action, plan) if plan.restart_gmail_watcher: plan.reload_hooks = True if mode == ReloadMode.HOT and plan.restart_gateway: plan.restart_gateway = False plan.restart_reasons.clear() for path in changed_paths: if path not in plan.noop_paths: plan.hot_reasons.append(path) return plan