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。
395 lines
15 KiB
Go
395 lines
15 KiB
Go
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)
|
||
}
|