Merge pull request 'Declare admin-zone sections from compiled plugins (surface 2.9.0, WP3-I3)' (#165) from feature/adminzone-plugin-sections into main
ci / vet-and-test (push) Successful in 46s
ci / vet-and-test (push) Successful in 46s
This commit was merged in pull request #165.
This commit is contained in:
@@ -27,6 +27,29 @@ type pluginRegistry struct {
|
||||
compiledRegistry
|
||||
pluginID string
|
||||
caps hostcaps
|
||||
// summary 是本插件的注册面摘要(WP3-I4):AddAdminZone 声明记录于此,
|
||||
// server 经 CompiledPlugins 供给分区清单端点(WP3-I3)。
|
||||
summary *server.CompiledPlugin
|
||||
}
|
||||
|
||||
// AddAdminZone 记录管理区分区声明(WP3-I3):声明即展示元数据,权威记录
|
||||
// 就是注册期摘要;规格非法(非对象、id/title 缺失)fail-closed 拒绝,
|
||||
// 使 register() 整体中止(与命令注册同一失败语义)。
|
||||
func (r pluginRegistry) AddAdminZone(spec json.RawMessage) error {
|
||||
if r.summary == nil {
|
||||
return errors.New("extension: " + r.pluginID + ": admin zone outside applyCompiled")
|
||||
}
|
||||
var s extension.AdminZoneSpec
|
||||
if err := json.Unmarshal(spec, &s); err != nil {
|
||||
return fmt.Errorf("extension: admin zone spec: %w", err)
|
||||
}
|
||||
if s.ID == "" || s.Title == "" {
|
||||
return fmt.Errorf("extension: %s: admin zone requires id and title", r.pluginID)
|
||||
}
|
||||
r.summary.AdminZones = append(r.summary.AdminZones, server.AdminZoneSection{
|
||||
ID: s.ID, Title: s.Title, Icon: s.Icon, Desc: s.Desc, Order: s.Order,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
|
||||
@@ -170,10 +193,10 @@ func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) []server.C
|
||||
if e.Register == nil {
|
||||
continue
|
||||
}
|
||||
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps}
|
||||
sum := server.CompiledPlugin{ID: e.ID}
|
||||
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps, summary: &sum}
|
||||
// Register 失败的插件保留已成功注册部分(与命令总线一致:部分注册
|
||||
// 生效),摘要如实反映实际注册面。
|
||||
sum := server.CompiledPlugin{ID: e.ID}
|
||||
if err := e.Register(recordRegistry{Registry: reg, sum: &sum}); err != nil {
|
||||
log.Printf("extension: %s: %v", e.ID, err)
|
||||
}
|
||||
|
||||
@@ -33,6 +33,13 @@ func TestApplyCompiledRecordsSummary(t *testing.T) {
|
||||
return err
|
||||
}
|
||||
_ = reg.AddSkill("demo-skill", "# 演示\n技能正文")
|
||||
// WP3-I3:管理区分区声明记入摘要;缺 id/title 的非法规格 fail-closed。
|
||||
if err := reg.AddAdminZone(json.RawMessage(`{"id":"demo.audit","title":"演示审计","icon":"search","order":20}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := reg.AddAdminZone(json.RawMessage(`{"title":"缺 id"}`)); err == nil {
|
||||
t.Fatal("admin zone without id should fail")
|
||||
}
|
||||
return nil
|
||||
}})
|
||||
extension.RegisterCompiled(extension.InProc{ID: "broken", Register: func(reg extension.Registry) error {
|
||||
@@ -66,6 +73,13 @@ func TestApplyCompiledRecordsSummary(t *testing.T) {
|
||||
if len(sums[0].Skills) != 1 || sums[0].Skills[0] != "demo-skill" {
|
||||
t.Fatalf("demo skills = %v", sums[0].Skills)
|
||||
}
|
||||
// WP3-I3:合法声明记入,非法规格(缺 id)被拒后摘要不受污染。
|
||||
if len(sums[0].AdminZones) != 1 {
|
||||
t.Fatalf("demo admin zones = %+v", sums[0].AdminZones)
|
||||
}
|
||||
if z := sums[0].AdminZones[0]; z.ID != "demo.audit" || z.Title != "演示审计" || z.Icon != "search" || z.Order != 20 {
|
||||
t.Fatalf("demo admin zone = %+v", z)
|
||||
}
|
||||
// broken:Register 报错但先成功的命令仍在总线,摘要如实保留。
|
||||
if sums[1].ID != "broken" || len(sums[1].Commands) != 1 || sums[1].Commands[0] != "broken.cmd" {
|
||||
t.Fatalf("broken summary = %+v", sums[1])
|
||||
|
||||
@@ -223,6 +223,7 @@ func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorize
|
||||
return nil
|
||||
}
|
||||
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
|
||||
func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil }
|
||||
|
||||
func TestPresentRejectsUnknownActionIDs(t *testing.T) {
|
||||
newRealSvc(t)
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/bus"
|
||||
"agent-system-backend/internal/plugins"
|
||||
"agent-system-backend/internal/profiles"
|
||||
"agent-system-backend/internal/runmgr"
|
||||
"agent-system-backend/internal/runtime"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/internal/tools"
|
||||
)
|
||||
|
||||
// WP3-I3:GET /api/admin-zone/sections 展平编译插件的分区声明并按
|
||||
// Order→ID 稳定排序;无声明时空数组(非 null)。
|
||||
func TestListAdminZoneSections(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
b := bus.New()
|
||||
reg := tools.NewRegistry()
|
||||
reg.RegisterBuiltin()
|
||||
setSvc := settings.NewService(st)
|
||||
profSvc := profiles.New(st)
|
||||
profSvc.EnsureDefault()
|
||||
mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
|
||||
ph := plugins.New(st, reg, setSvc, filepath.Join(dir, "plugins"))
|
||||
srv := New(st, mgr, reg, profSvc, setSvc, ph)
|
||||
srv.Auth = NewAuthenticator(st, true)
|
||||
srv.CompiledPlugins = []CompiledPlugin{
|
||||
{ID: "rdm", AdminZones: []AdminZoneSection{
|
||||
{ID: "rdm.audit", Title: "项目追溯与审计", Icon: "search", Order: 20},
|
||||
}},
|
||||
{ID: "other", AdminZones: []AdminZoneSection{
|
||||
{ID: "other.z", Title: "Z", Order: 10},
|
||||
{ID: "other.a", Title: "A", Order: 10},
|
||||
}},
|
||||
}
|
||||
h := srv.Handler()
|
||||
key, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
get := func(path string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
// 未认证拒绝。
|
||||
if rec := httptest.NewRecorder(); func() *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin-zone/sections", nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}().Code != http.StatusUnauthorized {
|
||||
t.Fatalf("unauthenticated request should 401")
|
||||
}
|
||||
rec := get("/api/admin-zone/sections")
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var got []AdminZoneSection
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantIDs := []string{"other.a", "other.z", "rdm.audit"} // Order 同为 10 的按 ID,再 Order 20
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("sections = %+v", got)
|
||||
}
|
||||
for i, w := range wantIDs {
|
||||
if got[i].ID != w {
|
||||
t.Fatalf("sections[%d] = %s, want %s (full: %+v)", i, got[i].ID, w, got)
|
||||
}
|
||||
}
|
||||
// 无声明时空数组非 null。
|
||||
srv.CompiledPlugins = nil
|
||||
if rec := get("/api/admin-zone/sections"); rec.Body.String() == "null" {
|
||||
t.Fatalf("empty listing must be [] not null: %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -325,6 +326,9 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /api/plugins/reload", s.reloadPlugins)
|
||||
mux.HandleFunc("GET /api/plugins/settings-schema", s.pluginSettingsSchema)
|
||||
mux.HandleFunc("GET /api/registry", s.registry)
|
||||
// WP3-I3/KTD4:管理区声明分区清单(编译插件经 Registry.AddAdminZone 声明;
|
||||
// 展示元数据对所有认证主体开放,分区内数据能力由组件命令面 fail-closed)。
|
||||
mux.HandleFunc("GET /api/admin-zone/sections", s.listAdminZoneSections)
|
||||
|
||||
// ---- 计划 / 待办 / 命令 / 解析器 / 检索 ----
|
||||
mux.HandleFunc("GET /api/conversations/{id}/plan", s.getPlan)
|
||||
@@ -1457,10 +1461,22 @@ func (s *Server) deleteUserTool(w http.ResponseWriter, r *http.Request) {
|
||||
// ID 即插件身份;Commands/Intents/Skills 为装载期成功注册的声明资产名。
|
||||
// 命令规格与处理器在命令总线,摘要只服务 /api/plugins 管理清单展示。
|
||||
type CompiledPlugin struct {
|
||||
ID string
|
||||
Commands []string
|
||||
Intents []string
|
||||
Skills []string
|
||||
ID string
|
||||
Commands []string
|
||||
Intents []string
|
||||
Skills []string
|
||||
AdminZones []AdminZoneSection
|
||||
}
|
||||
|
||||
// AdminZoneSection 是编译插件声明的管理区展示分区(WP3-I3/KTD4):
|
||||
// 字段与 extension.AdminZoneSpec 对齐(组合根装载期记录);入口不做基座级
|
||||
// 权限判定,分区内数据能力由组件自身命令面 fail-closed。
|
||||
type AdminZoneSection struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
Order int `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
type pluginView struct {
|
||||
@@ -1512,6 +1528,23 @@ func (s *Server) pluginSettingsSchema(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.plugins.SettingsSchema())
|
||||
}
|
||||
|
||||
// listAdminZoneSections 管理区声明分区清单(WP3-I3):展平全部编译插件的
|
||||
// 声明并按 Order→ID 稳定排序。声明是展示元数据,对所有认证主体一致;
|
||||
// 分区内可见数据由分区组件自身的命令面按 owner fail-closed 判定。
|
||||
func (s *Server) listAdminZoneSections(w http.ResponseWriter, r *http.Request) {
|
||||
var out []AdminZoneSection
|
||||
for _, cp := range s.CompiledPlugins {
|
||||
out = append(out, cp.AdminZones...)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Order != out[j].Order {
|
||||
return out[i].Order < out[j].Order
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
writeJSON(w, http.StatusOK, orEmptySlice(out))
|
||||
}
|
||||
|
||||
// registry 统一注册表视图(来源标记 + 按来源启停入口)。
|
||||
func (s *Server) registry(w http.ResponseWriter, r *http.Request) {
|
||||
reg := map[string]any{
|
||||
|
||||
@@ -25,7 +25,10 @@ import (
|
||||
// 2.8.0-experimental:EXT-17 新增可选能力接口 SurfaceHost(SurfaceSave/Get/List/
|
||||
// Activation;Generative UI U2)。实现由基座 hostcaps 注入;未装配时类型断言
|
||||
// 不成立即明确拒绝,不给必实现 Host 接口加方法(不破坏既有消费者)。
|
||||
const SurfaceVersion = "2.8.0-experimental"
|
||||
// 2.9.0-experimental:Registry.AddAdminZone 与 AdminZoneSpec(WP3-I3/KTD4:编译
|
||||
// 插件向基座管理区声明展示分区;分区入口不做基座级权限判定,数据能力由
|
||||
// 分区组件自身的命令面 fail-closed)。
|
||||
const SurfaceVersion = "2.9.0-experimental"
|
||||
|
||||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||
@@ -49,6 +52,23 @@ type Registry interface {
|
||||
// AddIntent 声明自然语言意图(G2-2):短语命中后路由到已注册命令——
|
||||
// 经同一命令总线分发(同审计信封与 HITL 收口)。规格见 IntentSpec。
|
||||
AddIntent(spec json.RawMessage) error
|
||||
// AddAdminZone 声明基座管理区展示分区(WP3-I3/KTD4):编译插件把自己的
|
||||
// 领域工作台以分区形式挂进管理区;声明只携带展示元数据,分区入口不做
|
||||
// 基座级权限判定——数据能力由分区组件自身的命令面 fail-closed。
|
||||
// 规格见 AdminZoneSpec。
|
||||
AddAdminZone(spec json.RawMessage) error
|
||||
}
|
||||
|
||||
// AdminZoneSpec 管理区分区声明(WP3-I3):ID 是分区身份(建议形如
|
||||
// <plugin>.<section>,前端按 ID 映射内置分区组件);Title/Icon/Desc 为展示
|
||||
// 元数据(icon 取基座前端 IconName 词表,未知值前端回退通用图标);Order
|
||||
// 越小越靠前,同 Order 按 ID 字典序。
|
||||
type AdminZoneSpec struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
Desc string `json:"desc,omitempty"`
|
||||
Order int `json:"order,omitempty"`
|
||||
}
|
||||
|
||||
// SurfaceSaveRequest 是一次页面提交(EXT-17):Spec 为 core.SurfaceSpec 的
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
AdminProvider
|
||||
AdminProvider.IsAdmin
|
||||
AdminZoneSpec
|
||||
Compiled
|
||||
ErrNotLinked
|
||||
Handler
|
||||
@@ -28,6 +29,7 @@ NewScopedHost
|
||||
Options
|
||||
RegisterCompiled
|
||||
Registry
|
||||
Registry.AddAdminZone
|
||||
Registry.AddCommand
|
||||
Registry.AddCommandHandler
|
||||
Registry.AddIntent
|
||||
|
||||
Reference in New Issue
Block a user