r1 复审 P2-1 + 两条顺手件。
P2-1(必修):`actionStringMap` / `actionStringList` 之前对非字符串值 `continue`,于是
前端 kv 控件里最自然的写法 `{"PORT":8080}` 会被**静默丢掉**、安装仍报 `installed:true`。
现在非字符串值、空键、非数组一律 400 invalid_config,文案只点名**键与类型**(不回显值);
盘上零变更、注册表零工具、快照零行(测试同时断言)。
顺手件 1:`isReservedPanelKey` 从「只挡 mcp」改成内置提供者闭集(mcp / web-search /
browser,写成 switch,新增内置页时漏补一眼可见)。之前一台名为 web-search 的 server 被
卸载时,`forgetPanels → MarkUninstalled` 会把内置「搜索提供商」页在重启前整页摘掉。
顺手件 2:启用「已连上但启用态未落盘」的文案改成事实(原来那句「它仍处于停用状态」与
事实相反)。为此新增 `ErrPluginStatePersist` 哨兵,把「磁盘没记住」与「连不上」分开;
`state=connected` 的派生口径不动。停用失败文案同时补上「server 仍在运行」。
测试:三条新用例,均已验「修前红 → 修后绿」(见 review-request 的取证)。
196 lines
7.5 KiB
Go
196 lines
7.5 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")
|
||
|
||
// 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
|
||
}
|