338 lines
12 KiB
Python
338 lines
12 KiB
Python
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]}.<redacted>"
|
|
return "<redacted>"
|
|
|
|
|
|
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
|