Files
Cyrene_v2/internal/capability/plugin/panels.go
T
AskaEth 9654ce8861 feat(plugin): MCP 服务器管理页 + 按 server 独立启停与状态快照
design/09 §5.2.1 / §5.6.1:补上「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」的洞。

- runtimeconfig:PluginConfig 新增 `enabled`(*bool,缺省=启用 ⇒ 既有 plugins.json
  向后兼容);新增 RedactPluginSecrets(整把凭据 + 任意 ≥6 字符片段一并抹除)。
- mcp:新增 status.go 的只读状态读面(ToolSummaries:工具名 + 截断描述,取自能力
  注册表本身,因此与模型可见的工具集合同源)。
- plugin 管理器:
  · Install/Restore 尊重 enabled(停用=落盘不连接,重启后仍是停用,且一次都不连);
  · SetEnabled 落盘 enabled(停用先落盘再下线;启用失败时 enabled 保持 false);
  · 新增 Reconnect(对已停用者拒绝,不替用户启用);
  · 新增 Statuses(三态:connected 缺席=未知,工具数/明细/时间只在知道时给出,
    lastError 在记录那一刻即脱敏截断);
  · 内置「MCP 服务器」页声明 + 7 个已声明动作(list/install/reconnect/enable/
    disable/uninstall/transports),走既有通用动作端点(owner-admin + 审计);
  · 护栏:热装插件的生命周期不改动内置提供者(mcp)的目录条目。
- gateway:已安装列表与 install 响应加 enabled,install 支持 config.enabled。
2026-10-24 19:31:31 +08:00

395 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package plugin
// 本文件把 design/09-插件生态 §5.2.1「插件管理器 = 唯一中枢」接到 PLG-1 的
// paneldecl 库上:声明登记 → 目录(catalog)→ 生命周期重算 → 变更通知。
// 「已声明动作」的读写面索引与分发在 panelactions.go,执行面登记在 actions.go。
//
// 为什么中枢放在插件管理器而不是网关:§5.2.1 明写「插件既不直接操作前端注册表,
// 也不直接读写自己的配置文件,两条路都必须经过插件管理器」。安装/卸载/启停的真源
// 就在 *Manager,面板集合是它的函数值 —— 两处各存一份状态必然分叉。
//
// 刻意不做的事:
// - 不注册 HTTP 端点、不认识 gateway(契约面在 internal/services/gateway);
// - 不读 manifest / 不读 config / 不读 env:声明由调用方(未来的 manifest 读取
// 切片、或宿主内置插件)交给 Declare,单一事实来源;
// - 不新开端口、不自己起 goroutine:变更只经注入的发布器(组合根把它接到既有
// 进程内 bus 的 `ws.panels_changed` 上)。
import (
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 面板集合变更的原因标签,也是 `panels_changed` 帧 `data.reason` 的取值集合
// (§5.2.1 生命周期一栏点名的四种变化 + 声明本身的变化)。
const (
// PanelChangeDeclared 报告某插件的面板声明被登记或替换。
PanelChangeDeclared = "declare"
// PanelChangeInstalled 报告某插件安装(或启动期恢复)成功。
PanelChangeInstalled = "install"
// PanelChangeUninstalled 报告某插件被卸载,其声明整条移除。
PanelChangeUninstalled = "uninstall"
// PanelChangeEnabled 报告某插件被启用。
PanelChangeEnabled = "enable"
// PanelChangeDisabled 报告某插件被停用(入口隐藏,声明保留)。
PanelChangeDisabled = "disable"
)
// 面板中枢的哨兵错误。调用方用 errors.Is 判类,不靠字符串匹配。
var (
// ErrActionNotDeclared 报告动作名不在该插件的声明集里 ⇒ 404(§5.2.1:
// 只接受该插件声明过的动作名)。
ErrActionNotDeclared = errors.New("plugin: action not declared")
// ErrActionUnavailable 报告动作已声明,但宿主没有接线执行器 ⇒ 501(诚实作答,
// 绝不假装执行成功)。
ErrActionUnavailable = errors.New("plugin: action executor is not wired")
// ErrPluginDisabled 报告插件已被显式停用,其动作不再分发 ⇒ 409。
ErrPluginDisabled = errors.New("plugin: plugin is disabled")
)
// PanelChange 是一次面板集合变更。它是发布给前端的热更新信号,**不含任何面板
// 内容**:前端按自己的角色重新拉一次 `GET /api/v1/admin/panels` 即可,避免
// 在帧里再实现一次角色过滤。
type PanelChange struct {
// Reason 是变更原因(本文件的 PanelChange* 常量)。
Reason string
// PluginID 是发生变化的插件 id。为空表示「无法归因到单个插件」,
// 调用方应把整份快照视为可能已变。
PluginID string
}
// Panels 是并发安全的面板声明中枢:目录 + 启用状态 + 动作索引 + 处理器表 + 变更发布。
//
// 它必须经 NewPanels 构造(目录需要非 nil 的 when 求值上下文来源)。
type Panels struct {
mu sync.RWMutex
cat *paneldecl.Catalog
decls map[string][]sdk.Panel
enabled map[string]bool
// handlers 是「插件 → 动作名 → 处理器」的登记表(见 RegisterActions)。
// 它与 decls(声明)是两个不同的事实:声明决定**可不可以调**,登记决定**谁来执行**。
handlers map[string]map[string]ActionHandler
publish func(PanelChange)
log *slog.Logger
}
// NewPanels 构造面板中枢。log 为 nil 时使用丢弃型 logger。
func NewPanels(log *slog.Logger) (*Panels, error) {
if log == nil {
log = slog.New(slog.DiscardHandler)
}
p := &Panels{
decls: make(map[string][]sdk.Panel),
enabled: make(map[string]bool),
handlers: make(map[string]map[string]ActionHandler),
log: log,
}
cat, err := paneldecl.NewCatalog(p.resolve)
if err != nil {
return nil, fmt.Errorf("plugin: panel catalog: %w", err)
}
p.cat = cat
return p, nil
}
// SetPublisher 注入变更发布器(组合根把它接到既有进程内 bus 的 `ws.panels_changed`)。
// nil 是合法值 ⇒ 只重算、不通知(单测与未接线的进程用得上)。
func (p *Panels) SetPublisher(pub func(PanelChange)) {
if p == nil {
return
}
p.mu.Lock()
p.publish = pub
p.mu.Unlock()
}
// Declare 用 panels 整体替换 pluginID 的声明集(§5.2.1 的「整体替换」口径),
// 返回真正生效的面板 id。非法插件 id 整插件拒绝(目录键不了它);某个面板非法
// 只拒该面板(口径与理由见 paneldecl.Catalog.Register)。
//
// 被拒面板的动作**不进**动作索引:§5.2.1 的「该面板不注册」意味着它的
// submit/source/rowActions 也不存在,否则面板没渲染出来、动作却能调用。
func (p *Panels) Declare(pluginID string, panels []sdk.Panel) ([]string, error) {
if p == nil {
return nil, fmt.Errorf("plugin: nil panel hub")
}
accepted, err := p.cat.Register(pluginID, panels)
if errors.Is(err, paneldecl.ErrInvalidPluginID) {
return accepted, err
}
p.mu.Lock()
p.decls[pluginID] = keepAccepted(panels, accepted)
p.mu.Unlock()
p.emit(PanelChange{Reason: PanelChangeDeclared, PluginID: pluginID})
return accepted, err
}
// MarkInstalled 报告某插件已安装(或启动期恢复成功):确保它处于启用态,
// 并推一次面板变更(§5.2.1:install 也重算面板集合)。
//
// pluginID 为空表示无法把该插件归因到目录键(见 PanelPluginID),此时只记日志 ——
// 没有合法命名空间就没有可挂的面板,这与 paneldecl「非法插件 id 整插件拒绝」同向。
func (p *Panels) MarkInstalled(pluginID string) {
p.setEnabled(pluginID, true, PanelChangeInstalled, true)
}
// MarkUninstalled 报告某插件已卸载:整条移除它的声明(面板与动作一起消失)。
func (p *Panels) MarkUninstalled(pluginID string) {
if p == nil || pluginID == "" {
return
}
p.cat.Remove(pluginID)
p.mu.Lock()
delete(p.decls, pluginID)
delete(p.enabled, pluginID)
p.mu.Unlock()
p.emit(PanelChange{Reason: PanelChangeUninstalled, PluginID: pluginID})
}
// SetEnabled 显式启用或停用一个插件:停用后 `when: {enabled:true}` 的入口不出现
// (§5.6.1),声明本身保留(重新启用即原样回来)。启用态**未变化时不发通知**,
// 避免「点两次启用」推两次无意义的帧。
func (p *Panels) SetEnabled(pluginID string, on bool) {
reason := PanelChangeDisabled
if on {
reason = PanelChangeEnabled
}
p.setEnabled(pluginID, on, reason, false)
}
// Enabled 报告插件当前是否启用。**缺省为启用**:声明只在安装期存在(卸载即整条
// 移除),所以「没有显式停用记录」等于启用;只有 SetEnabled(false) 会写下 false。
// 这样 `when:{enabled:true}` 的入口在正常安装后可见,`when:{enabled:false}` 的入口
// 要等显式停用才出现 —— 两条语义都可表达,且不需要第二份「装没装」的事实。
func (p *Panels) Enabled(pluginID string) bool {
if p == nil {
return false
}
p.mu.RLock()
defer p.mu.RUnlock()
return p.enabledLocked(pluginID)
}
// List 返回按角色过滤后的面板快照(含 when 求值),排序与 paneldecl 一致。
func (p *Panels) List(role auth.Role) ([]paneldecl.Entry, error) {
if p == nil {
return nil, fmt.Errorf("plugin: nil panel hub")
}
return p.cat.List(role)
}
// Groups 返回按侧边栏分组组织的同一份快照(§5.2.1 的 nav.group 一级)。
func (p *Panels) Groups(role auth.Role) ([]paneldecl.Group, error) {
if p == nil {
return nil, fmt.Errorf("plugin: nil panel hub")
}
return p.cat.Groups(role)
}
// PluginIDs 返回当前登记过声明的插件 id(已排序)。**不等于已安装插件**:
// 它是 §5.2.1 红线③的支撑面 —— 前端可拿它与已安装列表对照,把「已装、未声明
// 任何页面」的插件如实显示成「未声明管理页面」。
func (p *Panels) PluginIDs() []string {
if p == nil {
return nil
}
return p.cat.PluginIDs()
}
// setEnabled 是启用态变更的唯一实现(force=true 表示无论是否变化都发通知,
// 供 install/restore 这类「生命周期事件本身就值得一帧」的场合使用)。
func (p *Panels) setEnabled(pluginID string, on bool, reason string, force bool) {
if p == nil {
return
}
if pluginID == "" {
p.log.Warn("panel change without a catalog key", "reason", reason)
return
}
p.mu.Lock()
prev := p.enabledLocked(pluginID)
p.enabled[pluginID] = on
p.mu.Unlock()
if !force && prev == on {
return
}
p.emit(PanelChange{Reason: reason, PluginID: pluginID})
}
// emit 把一次变更交给发布器。它在状态锁**之外**调用:发布器会碰 bus(外部代码),
// 持锁调用等于把中枢的锁交给调用方(本项目踩过同类自锁死)。
func (p *Panels) emit(ch PanelChange) {
if p == nil {
return
}
p.mu.RLock()
pub := p.publish
p.mu.RUnlock()
if pub == nil {
return
}
pub(ch)
}
// resolve 是目录的 when 求值上下文来源。它只读启用状态,**绝不**回调目录方法
// (paneldecl 明令:Resolver 回调 Register/List/Groups 会与目录锁自锁死)。
func (p *Panels) resolve(pluginID string) paneldecl.Context {
p.mu.RLock()
defer p.mu.RUnlock()
return paneldecl.Context{Enabled: p.enabledLocked(pluginID)}
}
// enabledLocked 读取启用态(缺省启用,见 Enabled)。调用方必须持有 p.mu。
func (p *Panels) enabledLocked(pluginID string) bool {
on, ok := p.enabled[pluginID]
if !ok {
return true
}
return on
}
// PanelPluginID 给出一个已安装插件在面板目录里的键(命名空间)。
//
// 口径:**优先 Name**(它是 plugins.json 的唯一标签、跨重启稳定、也是用户/面板
// 自然使用的地址),Name 不是合法目录键时回落 ID(runtimeconfig.NewID 生成的
// 32 位十六进制,形状天然合法),两者都不合法时返回空串(该插件无法拥有面板)。
//
// 为什么不优先 ID:首次安装时 install 拿到的 cfg.ID 还是空的(ID 由 UpsertPlugin
// 在落盘那一刻才生成),按 ID 建键会让同一个插件在「首次安装」与「重启后恢复」两次
// 得到不同的键 —— 那正是「重启后面板消失」的事故形状。导出它是为了让未来读取
// manifest 的切片复用**同一个**函数取键,不出现第二个口径。
func PanelPluginID(cfg runtimeconfig.PluginConfig) string {
if PanelIDValid(strings.TrimSpace(cfg.Name)) {
return strings.TrimSpace(cfg.Name)
}
if id := strings.TrimSpace(cfg.ID); PanelIDValid(id) {
return id
}
return ""
}
// PanelIDValid 报告 id 能否直接当面板目录键(路径段形状)。
// 判据直接问 paneldecl,不复制它的正则 —— 口径只能有一份。
func PanelIDValid(id string) bool {
return id != "" && paneldecl.Validate(id, nil) == nil
}
// --- 管理器侧的面板衔接(*Manager 与 *Panels 的结合面) -----------------------
// markInstalled 记录安装时用过的面板目录键(memo,供卸载/停用复用),并把它报给
// 面板中枢。未接线时只记 memo —— 计时与归因不依赖中枢在不在。
func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason string) {
key := m.rememberPanelKey(cfg)
if key == "" || isReservedPanelKey(key) {
return
}
hub := m.panelsHub()
if hub == nil {
return
}
// 由「启用」触发的重连走 enable 语义(入口从隐藏变可见),其余(安装/启动期恢复)
// 走 install 语义。
if changeReason == PanelChangeEnabled {
hub.SetEnabled(key, true)
return
}
hub.MarkInstalled(key)
}
// markDisabled 记录停用时用过的面板目录键,并让中枢隐藏该插件 `when:{enabled:true}` 的
// 入口(声明保留,重新启用即原样回来)。
//
// 「停用」是**有意**没连,因此入口必须消失(§5.6.1「未启用的插件 ⇒ 入口不出现」);
// 而「连不上」不该摘入口 —— 那时用户正需要点「重连」。
func (m *Manager) markDisabled(cfg runtimeconfig.PluginConfig) {
key := m.rememberPanelKey(cfg)
if key == "" || isReservedPanelKey(key) {
return
}
if hub := m.panelsHub(); hub != nil {
hub.SetEnabled(key, false)
}
}
// rememberPanelKey 记下该插件的面板目录键(memo)并回它;键非法时记日志并回空串。
//
// memo 是**缓存而不是第二份真源**:键由 PanelPluginID 从 cfg 推出,这里只把结果留下,
// 好让「活 server 已不在了」的卸载/停用仍能精确找到该清哪一条。
func (m *Manager) rememberPanelKey(cfg runtimeconfig.PluginConfig) string {
key := PanelPluginID(cfg)
if key == "" {
m.log.Warn("plugin has no panel catalog key", "name", cfg.Name)
return ""
}
if isReservedPanelKey(key) {
// 内置提供者的命名空间不归热装插件管:一台恰好叫 mcp 的 server 不许改动
// 「MCP 服务器」页本身(见 isReservedPanelKey)。
m.log.Info("plugin name collides with a built-in panel namespace; panel entry untouched",
"name", cfg.Name, "panelId", key)
return ""
}
m.mu.Lock()
m.panelKeys[cfg.Name] = key
m.mu.Unlock()
return key
}
// panelsHub 返回已接线中枢(未接线时 nil)。
func (m *Manager) panelsHub() *Panels {
m.mu.Lock()
defer m.mu.Unlock()
return m.panels
}
// forgetPanels 在卸载后清掉该插件的面板。键优先取安装时记下的 memo;没有记录
// (例如配置文件是手工放进 plugins.json 的)时回落到「名字恰好合法就用名字」。
func (m *Manager) forgetPanels(name string) {
m.mu.Lock()
key := m.panelKeys[name]
delete(m.panelKeys, name)
hub := m.panels
m.mu.Unlock()
if hub == nil {
return
}
if key == "" {
key = strings.TrimSpace(name)
if !PanelIDValid(key) {
return
}
}
if isReservedPanelKey(key) {
return
}
hub.MarkUninstalled(key)
}
// setPanelsEnabled 把启用态变化交给面板中枢(未接线时空操作)。
func (m *Manager) setPanelsEnabled(name string, on bool) {
m.mu.Lock()
key := m.panelKeys[name]
hub := m.panels
m.mu.Unlock()
if hub == nil {
return
}
if key == "" {
key = strings.TrimSpace(name)
if !PanelIDValid(key) {
return
}
}
if isReservedPanelKey(key) {
return
}
hub.SetEnabled(key, on)
}