ForcePilot/backend/package/yuxi/channel/config/reload_plan.py
Kris 4962d1f6f8 feat(channel/config): 新增完整的配置模块实现
新增了channel目录下的配置相关模块,包含默认配置、配置迁移、配置差异计算、文件加载器、重载计划、重载器以及配置校验功能,完善了配置管理体系
2026-05-21 10:24:03 +08:00

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