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。
187 lines
6.8 KiB
Go
187 lines
6.8 KiB
Go
package plugin
|
||
|
||
// 本文件是「按 server 独立启停」的落点(切片 M20):启用态**持久化**在 plugins.json 的
|
||
// `enabled` 字段上,因此停用在重启后仍然有效;热切换的语义是「停用 = 摘掉工具,启用 =
|
||
// 连上并重新发现」,两条路都经插件管理器(design/09 §5.2.1「插件管理器 = 唯一中枢」)。
|
||
//
|
||
// 为什么这些函数从 manager.go 搬出来:manager.go 已接近「文件 ≤500 行」的上限,而启停
|
||
// 生命周期本身就有四件事——落盘、内存态、面板入口、运行期观察——挤在一起会既超行数又
|
||
// 说不清顺序。顺序在这里写死:**先落盘、再动活 server、最后改面板入口**;落盘失败即
|
||
// 原样返回错误,绝不出现「面板说停用了、重启又连上」。
|
||
//
|
||
// 刻意不做的事:
|
||
// - 停用**不动** plugins.json 的条目(停用 ≠ 卸载,卸载才删配置);
|
||
// - 启用的失败**不改**已落盘的 enabled(失败仍禁用,如实报错,不假装开着)。
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||
)
|
||
|
||
// ErrPluginNotFound 报告指定的插件既没活着也不在 plugins.json 里。
|
||
var ErrPluginNotFound = errors.New("plugin: not found")
|
||
|
||
// notFound 构造一条带名字的「找不到」。
|
||
func notFound(name string) error {
|
||
if name == "" {
|
||
return fmt.Errorf("%w: empty name", ErrPluginNotFound)
|
||
}
|
||
return fmt.Errorf("%w: %s", ErrPluginNotFound, name)
|
||
}
|
||
|
||
// SetEnabled 启用或停用一台已安装的 server。
|
||
//
|
||
// 语义(design/09 §5.2.1 生命周期一栏):
|
||
// - 停用:落盘 `enabled:false` → 关掉活的 server(其工具随之下线,模型看不见)→ 隐藏
|
||
// 它的管理面板入口(`when:{enabled:true}` 的入口不再出现)。配置保留,重启后仍是停用。
|
||
// - 启用:按 plugins.json 的配置重新连接 + 发现 + 注册 → 入口回来 → 落盘 `enabled:true`。
|
||
// **连接失败时返回值即错误,且落盘的 enabled 保持 false**(不假装开着)。
|
||
//
|
||
// 两条路都会推一次 `panels_changed`(停用推 disable、启用推 enable)。
|
||
// 找不到该 server 时返回 ErrPluginNotFound。
|
||
func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" {
|
||
return notFound("")
|
||
}
|
||
if !on {
|
||
return m.disable(ctx, name)
|
||
}
|
||
return m.enable(ctx, name)
|
||
}
|
||
|
||
// Reconnect 断开并重新连接一台已启用的 server(重新发现工具,重新注册)。
|
||
//
|
||
// 它与「先停用再启用」的差别只在面板帧的原因标签与「已停用时拒绝」:重连不改变启用态,
|
||
// 所以它既不写 enabled,也不该替用户启用一个被停用的 server(那会让「为什么它忽然活了」
|
||
// 变成一个查不出的谜)。
|
||
func (m *Manager) Reconnect(ctx context.Context, name string) (Installed, error) {
|
||
name = strings.TrimSpace(name)
|
||
cfg, ok := m.persistedConfig(name)
|
||
if !ok {
|
||
if srv := m.liveServer(name); srv != nil {
|
||
cfg, ok = srv.cfg, true
|
||
}
|
||
}
|
||
if !ok {
|
||
return Installed{}, notFound(name)
|
||
}
|
||
if !cfg.IsEnabled() {
|
||
return Installed{}, fmt.Errorf("%w: %s", ErrPluginDisabled, name)
|
||
}
|
||
return m.install(ctx, cfg, false, PanelChangeInstalled)
|
||
}
|
||
|
||
// disable 停用一台 server:落盘 → 下线 → 隐藏入口。
|
||
//
|
||
// 落盘在**前面**:先写文件的好处是「写不进去时 server 还在跑」,而不是先摘掉工具再
|
||
// 报一个落盘错误(那样内存与文件分叉,用户看到的与重启后得到的不是一回事)。
|
||
func (m *Manager) disable(ctx context.Context, name string) error {
|
||
srv := m.liveServer(name)
|
||
cfg, persisted := m.persistedConfig(name)
|
||
if srv == nil && !persisted {
|
||
return notFound(name)
|
||
}
|
||
if !persisted {
|
||
// 文件里没有这条,但 server 活着(手工塞进来的):用活 server 的配置补一条,
|
||
// 否则「停用」重启后会变成「不存在」。
|
||
cfg = srv.cfg
|
||
}
|
||
if err := m.persistEnabled(cfg, false); err != nil {
|
||
return fmt.Errorf("plugin %q: %w", name, err)
|
||
}
|
||
if srv != nil {
|
||
m.take(srv.cfg.Name)
|
||
m.stop(ctx, srv)
|
||
}
|
||
m.noteDisabled(cfg)
|
||
m.setPanelsEnabled(cfg.Name, false)
|
||
m.log.Info("plugin disabled", "name", cfg.Name)
|
||
return nil
|
||
}
|
||
|
||
// enable 重新启用一台已安装的 server:按 plugins.json 的配置重新连接。
|
||
// 已经活着的 server 是幂等空操作(只把文件里的 enabled 纠正为 true,不推多余帧)。
|
||
func (m *Manager) enable(ctx context.Context, name string) error {
|
||
cfg, ok := m.persistedConfig(name)
|
||
if !ok {
|
||
if srv := m.liveServer(name); srv != nil {
|
||
return m.persistEnabled(srv.cfg, true)
|
||
}
|
||
return notFound(name)
|
||
}
|
||
if srv := m.liveServer(name); srv != nil {
|
||
if cfg.IsEnabled() {
|
||
return nil
|
||
}
|
||
return m.persistEnabled(cfg, true)
|
||
}
|
||
// 只有连上才落 enabled=true:连接/发现失败时文件保持停用,返回值就是那条失败。
|
||
res, err := m.install(ctx, cfg.WithEnabled(true), false, PanelChangeEnabled)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return m.persistEnabled(res.Config, true)
|
||
}
|
||
|
||
// persistEnabled 把启用态写回 plugins.json(upsert by name,其余字段原样保留)。
|
||
//
|
||
// 它**不**碰内存里的活 server:调用方按自己的顺序决定何时连线/断线。
|
||
func (m *Manager) persistEnabled(cfg runtimeconfig.PluginConfig, on bool) error {
|
||
if err := m.persistUpsert(cfg.WithEnabled(on)); err != nil {
|
||
return fmt.Errorf("插件 %q 的启用态未落盘: %w", cfg.Name, err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// liveServer 返回活着的 server(按名字或 id 匹配,无则 nil)。
|
||
func (m *Manager) liveServer(name string) *server {
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
if srv := m.servers[name]; srv != nil {
|
||
return srv
|
||
}
|
||
for _, srv := range m.servers {
|
||
if srv.cfg.ID != "" && srv.cfg.ID == name {
|
||
return srv
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// takeAny 按名字或 id 摘掉活着的 server(take 只认精确的名字键)。
|
||
func (m *Manager) takeAny(name string) *server {
|
||
if srv := m.take(name); srv != nil {
|
||
return srv
|
||
}
|
||
m.mu.Lock()
|
||
defer m.mu.Unlock()
|
||
for key, srv := range m.servers {
|
||
if srv.cfg.ID != "" && srv.cfg.ID == name {
|
||
delete(m.servers, key)
|
||
return srv
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// persistedConfig 按名(或 id)在 plugins.json 里找一条配置。文件读不出来时按
|
||
// 「没有这条」处理并记日志:停用/启用不该因为一个坏文件而变成 500。
|
||
func (m *Manager) persistedConfig(name string) (runtimeconfig.PluginConfig, bool) {
|
||
cfgs, err := runtimeconfig.LoadPlugins(m.path)
|
||
if err != nil {
|
||
m.log.Warn("plugins.json load failed", "path", m.path, "error", err)
|
||
return runtimeconfig.PluginConfig{}, false
|
||
}
|
||
for _, cfg := range cfgs {
|
||
if cfg.Name == name || (cfg.ID != "" && cfg.ID == name) {
|
||
return cfg, true
|
||
}
|
||
}
|
||
return runtimeconfig.PluginConfig{}, false
|
||
}
|