feat(plugins): 插件管理器接线 —— 面板接入点快照 + 动作分发(PLG-2)
- GET /api/v1/admin/panels:按调用者角色过滤后的接入点快照(PLG-1 的 catalog 挂进插件管理器)
- POST /api/v1/admin/plugins/{id}/actions/{action}:**只接受该插件声明过的动作名**
(未声明 ⇒ 404);写动作 writeAuth + 落审计;插件只给动作名不给 URL ⇒ 不给插件开任意请求面
- 生命周期:install/uninstall/enable/disable 变化后**重算面板集合**并经进程内 bus 推 panels_changed
(前端可热更新;不新开端口)
契约 v0.78 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。
This commit is contained in:
1 parent
c907a34510
commit
2d21aa553e
9 files changed
+1990
-6
No files matched your search
@@ -3,15 +3,19 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// capacityEmitter 是面向网关的 capacity_awaken 广播出口。
|
||||
@@ -26,6 +30,10 @@ type capacityEmitter interface {
|
||||
func (a *app) wireFrontend() error {
|
||||
jwt := auth.New(a.cfg.Auth)
|
||||
apiKeys := a.wireAPIKeys()
|
||||
// 插件管理面板声明中枢(design/09 §5.2.1;切片 PLG-2)必须挂在**同一个**插件管理器上
|
||||
// (两处各存一份生命周期事实必然分叉),且在 gateway.New 之前接好:网关的两条面板端点
|
||||
// 都经 s.plugins.Panels() 取它。见 wirePanelHub。
|
||||
wirePanelHub(a)
|
||||
ragStore := knowledge.New(knowledge.Deps{
|
||||
Projects: a.projStore,
|
||||
Retriever: a.retriever,
|
||||
@@ -155,6 +163,65 @@ func (a *app) wireFrontend() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// wirePanelHub 给已构造的插件管理器接上面板声明中枢,并把它接到既有进程内 bus 的
|
||||
// `ws.panels_changed` 上(design/09 §5.2.1;切片 PLG-2)。
|
||||
//
|
||||
// 三条约束都在这里落地:
|
||||
// - **不新开端口**:热更新信号只走进程内 bus(hub 订阅 ws.> ⇒ 帧类型 panels_changed),
|
||||
// 与既有实时帧同一根管子;
|
||||
// - **帧不带内容**:载荷只有 reason / pluginId,前端按自己的角色重拉一次
|
||||
// GET /api/v1/admin/panels,避免在帧里再实现一遍角色过滤(也就不会漏过门);
|
||||
// - **接线失败不阻断启动**:中枢构造失败只告警(网关随后对两条面板端点诚实回 501),
|
||||
// 与 refresh store / key store 的降级口径一致。
|
||||
func wirePanelHub(a *app) {
|
||||
if a.plugins == nil {
|
||||
return
|
||||
}
|
||||
hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "capability/plugin/panels"))
|
||||
if err != nil {
|
||||
a.log.Warn("panel hub unavailable, plugin panel endpoints will answer 501", "error", err)
|
||||
return
|
||||
}
|
||||
hub.SetPublisher(func(ch pluginmgr.PanelChange) {
|
||||
if a.bus == nil {
|
||||
return
|
||||
}
|
||||
if err := a.bus.Publish(context.Background(), "ws.panels_changed", map[string]any{
|
||||
"reason": ch.Reason,
|
||||
"pluginId": ch.PluginID,
|
||||
}); err != nil {
|
||||
a.log.Warn("panels_changed publish failed", "reason", ch.Reason, "pluginId", ch.PluginID, "error", err)
|
||||
}
|
||||
})
|
||||
a.plugins.AttachPanels(hub)
|
||||
declareBuiltinPanels(hub, logger.WithSource(a.log, "capability/plugin/panels"))
|
||||
}
|
||||
|
||||
// declareBuiltinPanels 把**编译进二进制的内置插件**的面板声明交给中枢
|
||||
// (design/09 §5.5 的 dogfood:「网络搜索 → 搜索提供商」页)。
|
||||
//
|
||||
// 为什么在组合根做这一步:内置插件的声明与它自己的能力同源(`plugins/tools/io/search`
|
||||
// 里就摆着 `AdminPanels()`),没有卸载/安装路径 ⇒ 只有「装配期登记一次」这一种生命周期。
|
||||
// 热装 MCP 插件的声明走另一条路(manifest 读取 + 生命周期重算,后续切片)。
|
||||
//
|
||||
// 声明被拒只告警、不阻断启动:一个坏声明不该让整个后端起不来(§5.2.1 的口径是
|
||||
// 「该面板不注册 + 显式报错」,不是「宿主陪葬」)。
|
||||
func declareBuiltinPanels(hub *pluginmgr.Panels, log *slog.Logger) {
|
||||
declarations := []struct {
|
||||
pluginID string
|
||||
panels []sdk.Panel
|
||||
}{
|
||||
{search.PanelPluginID, search.AdminPanels()},
|
||||
}
|
||||
for _, d := range declarations {
|
||||
accepted, err := hub.Declare(d.pluginID, d.panels)
|
||||
if err != nil {
|
||||
log.Warn("builtin panel declaration rejected",
|
||||
"pluginId", d.pluginID, "accepted", accepted, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// wireAPIKeys 构造 API key 管理器,并把落盘载体接到它上面(K1:修「每次重启 key
|
||||
// 全失效」——此前 key 表只在内存里,主人刚签的 key 被一次重启全部打掉,签发端点
|
||||
// 长凭据的语义与「重启即作废」自相矛盾)。
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
|
||||
)
|
||||
|
||||
// 本文件锁定组合根的面板接线(切片 PLG-2):wirePanelHub 必须把插件管理器的面板
|
||||
// 中枢接到**既有进程内 bus** 的 `ws.panels_changed` 上 —— 前端热更新靠这条帧,
|
||||
// 且不新开任何端口(单端口红线)。
|
||||
//
|
||||
// 为什么在组合根测:帧的主题名与载荷形状是跨片契约(前端片按它消费),
|
||||
// 只有对 wirePanelHub 取证才能证明「组合根真的发的是这一条」。
|
||||
|
||||
// TestWirePanelHubPublishesPanelsChanged 断言生命周期变化真的经 bus 推出
|
||||
// `ws.panels_changed`,且载荷带 reason / pluginId(不含任何面板内容)。
|
||||
func TestWirePanelHubPublishesPanelsChanged(t *testing.T) {
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
|
||||
defer mgr.Close(context.Background())
|
||||
|
||||
wirePanelHub(&app{plugins: mgr, bus: b, log: testLog()})
|
||||
hub := mgr.Panels()
|
||||
if hub == nil {
|
||||
t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上")
|
||||
}
|
||||
|
||||
events := make(chan bus.Event, 8)
|
||||
if _, err := b.Subscribe(context.Background(), "ws.panels_changed", func(_ context.Context, e bus.Event) error {
|
||||
events <- e
|
||||
return nil
|
||||
}); err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
|
||||
enabled := true
|
||||
panels := []sdk.Panel{{
|
||||
ID: "providers", Title: "搜索提供商", Order: 1,
|
||||
When: &sdk.When{Enabled: &enabled},
|
||||
Sections: []sdk.Section{{
|
||||
Type: sdk.SectionStatus, ID: "chain", Title: "降级链",
|
||||
Fields: []sdk.Field{{Key: "active", Type: sdk.FieldText}},
|
||||
}},
|
||||
}}
|
||||
if _, err := hub.Declare("web-search", panels); err != nil {
|
||||
t.Fatalf("Declare: %v", err)
|
||||
}
|
||||
hub.SetEnabled("web-search", false)
|
||||
|
||||
for _, want := range []string{"declare", "disable"} {
|
||||
select {
|
||||
case e := <-events:
|
||||
if e.Subject != "ws.panels_changed" {
|
||||
t.Fatalf("subject = %q", e.Subject)
|
||||
}
|
||||
payload, ok := e.Payload.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("载荷类型 = %T,期望 map[string]any", e.Payload)
|
||||
}
|
||||
if payload["reason"] != want {
|
||||
t.Fatalf("reason = %v,期望 %q", payload["reason"], want)
|
||||
}
|
||||
if payload["pluginId"] != "web-search" {
|
||||
t.Fatalf("pluginId = %v,期望 web-search", payload["pluginId"])
|
||||
}
|
||||
// 帧刻意不带内容:前端按自己的角色重拉快照,避免在这里再实现一遍角色过滤。
|
||||
if _, leaked := payload["panels"]; leaked {
|
||||
t.Fatalf("panels_changed 帧不得携带面板内容:%v", payload)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("未收到 %q 的 panels_changed 帧", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWirePanelHubDeclaresBuiltinSearchPanel 断言内置插件(web-search)的面板声明
|
||||
// 真的进了目录 —— 没有这一步,契约面在运行期就是一份空快照(design/09 §5.5 的 dogfood
|
||||
// 也就无从验起)。顺带锁定「声明通过校验」:search.AdminPanels() 若被改坏,这里会红。
|
||||
func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) {
|
||||
b := bus.New()
|
||||
defer b.Close()
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
|
||||
defer mgr.Close(context.Background())
|
||||
|
||||
wirePanelHub(&app{plugins: mgr, bus: b, log: testLog()})
|
||||
hub := mgr.Panels()
|
||||
|
||||
entries, err := hub.List(auth.RoleOwner)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].PluginID != search.PanelPluginID ||
|
||||
entries[0].Panel.ID != search.PanelIDProviders {
|
||||
t.Fatalf("内置面板 = %+v,期望 web-search/providers 一条", entries)
|
||||
}
|
||||
for _, name := range search.ActionNames() {
|
||||
if _, ok := hub.Action(search.PanelPluginID, name); !ok {
|
||||
t.Fatalf("内置插件声明的动作 %q 未进白名单", name)
|
||||
}
|
||||
}
|
||||
// viewer 拿到的必须是裁掉 form 的只读投影。
|
||||
viewer, err := hub.List(auth.RoleViewer)
|
||||
if err != nil {
|
||||
t.Fatalf("List(viewer): %v", err)
|
||||
}
|
||||
if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 {
|
||||
t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewer)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。
|
||||
func TestWirePanelHubNilPluginsIsNoop(t *testing.T) {
|
||||
wirePanelHub(&app{bus: bus.New(), log: testLog()})
|
||||
}
|
||||
|
||||
// TestWirePanelHubWithoutBusIsNoop 断言没有 bus 时也只告警、不 panic
|
||||
// (发布器内部判空,与 refresh store / key store 的降级口径一致)。
|
||||
func TestWirePanelHubWithoutBusIsNoop(t *testing.T) {
|
||||
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog())
|
||||
defer mgr.Close(context.Background())
|
||||
wirePanelHub(&app{plugins: mgr, log: testLog()})
|
||||
hub := mgr.Panels()
|
||||
if hub == nil {
|
||||
t.Fatal("没有 bus 时仍应接上面板中枢(只是不发帧)")
|
||||
}
|
||||
if _, err := hub.Declare("web-search", nil); err != nil {
|
||||
t.Fatalf("Declare: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user