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") // ErrPluginStatePersist 报告「启用态没写成」:内存/连接态可能已经变了,但 plugins.json // 还是旧值 —— 因此**重启后会退回旧状态**。 // // 它与「连不上」是两件相反的事,调用方必须能分开(文案说反就是骗人):连不上 ⇒ server // 现在不可用;落盘失败 ⇒ server 可能正连着、工具可用,只是这个事实活不过一次重启。 var ErrPluginStatePersist = errors.New("plugin: enabled state not persisted") // 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:调用方按自己的顺序决定何时连线/断线。 // 失败用 ErrPluginStatePersist 包起来:调用方要能把它与「连不上」区分开 —— 这两个失败 // 的事实相反(一个没连上、一个可能已经连上而磁盘没记住)。 func (m *Manager) persistEnabled(cfg runtimeconfig.PluginConfig, on bool) error { if err := m.persistUpsert(cfg.WithEnabled(on)); err != nil { return fmt.Errorf("%w: 插件 %q 的启用态未落盘: %w", ErrPluginStatePersist, 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 }