Merge pull request 'List compiled plugins in /api/plugins (WP3-I4)' (#162) from feature/plugins-compiled-listing into main
ci / vet-and-test (push) Successful in 45s
ci / vet-and-test (push) Successful in 45s
This commit was merged in pull request #162.
This commit is contained in:
+3
-1
@@ -563,7 +563,7 @@ func Run(opt Options) error {
|
||||
mgr.SetCommandSource(busAdapter{bus: bus})
|
||||
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
|
||||
wireScopedHostFactory(caps)
|
||||
applyCompiled(skillsRoot, bus, caps)
|
||||
compiledSummary := applyCompiled(skillsRoot, bus, caps)
|
||||
mgr.OnUsage = func(owner string, tokens int) {
|
||||
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
|
||||
status, aerr := st.AccountTokens(owner, tokens, budget)
|
||||
@@ -617,6 +617,8 @@ func Run(opt Options) error {
|
||||
mgr.SetWorkflows(wfReg)
|
||||
srv.Cards = cardSvc
|
||||
srv.Surfaces = surfaceSvc
|
||||
// WP3-I4:编译插件摘要进 /api/plugins 管理清单(注册面即清单面)。
|
||||
srv.CompiledPlugins = compiledSummary
|
||||
srv.Bus = bus
|
||||
srv.Gate = gate
|
||||
srv.Prompts = promptSvc
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/server"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -105,17 +106,80 @@ func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) {
|
||||
// recordRegistry 包装 per-plugin 注册表,把成功注册的声明资产名记入摘要
|
||||
// (WP3-I4:注册面即清单面——编译插件的管理条目不另设声明口)。
|
||||
type recordRegistry struct {
|
||||
extension.Registry
|
||||
sum *server.CompiledPlugin
|
||||
}
|
||||
|
||||
func (r recordRegistry) AddCommand(spec json.RawMessage) error {
|
||||
if err := r.Registry.AddCommand(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if id := specID(spec); id != "" {
|
||||
r.sum.Commands = append(r.sum.Commands, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r recordRegistry) AddCommandHandler(spec json.RawMessage, h extension.Handler) error {
|
||||
if err := r.Registry.AddCommandHandler(spec, h); err != nil {
|
||||
return err
|
||||
}
|
||||
if id := specID(spec); id != "" {
|
||||
r.sum.Commands = append(r.sum.Commands, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r recordRegistry) AddIntent(spec json.RawMessage) error {
|
||||
if err := r.Registry.AddIntent(spec); err != nil {
|
||||
return err
|
||||
}
|
||||
if id := specID(spec); id != "" {
|
||||
r.sum.Intents = append(r.sum.Intents, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r recordRegistry) AddSkill(name, markdown string) error {
|
||||
if err := r.Registry.AddSkill(name, markdown); err != nil {
|
||||
return err
|
||||
}
|
||||
r.sum.Skills = append(r.sum.Skills, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// specID 宽松提取 spec 顶层 id:仅服务清单摘要展示,解析失败返回空串
|
||||
// 不记录,不影响注册结果本身。
|
||||
func specID(spec json.RawMessage) string {
|
||||
var s struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := json.Unmarshal(spec, &s); err != nil {
|
||||
return ""
|
||||
}
|
||||
return s.ID
|
||||
}
|
||||
|
||||
func applyCompiled(skillsRoot string, bus *cmdbus.Bus, caps hostcaps) []server.CompiledPlugin {
|
||||
base := compiledRegistry{skillsRoot: skillsRoot, bus: bus}
|
||||
var out []server.CompiledPlugin
|
||||
for _, e := range extension.Compiled() {
|
||||
if e.Register == nil {
|
||||
continue
|
||||
}
|
||||
reg := pluginRegistry{compiledRegistry: base, pluginID: e.ID, caps: caps}
|
||||
if err := e.Register(reg); err != nil {
|
||||
// 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)
|
||||
}
|
||||
out = append(out, sum)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeMCPServers(raw string, extra []core.PluginMCPServer) string {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/cmdbus"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
// WP3-I4:applyCompiled 的注册面摘要——成功注册的命令/意图/技能记入
|
||||
// CompiledPlugin,失败注册不记录,Register 整体失败保留已成功部分。
|
||||
func TestApplyCompiledRecordsSummary(t *testing.T) {
|
||||
extension.ResetCompiled()
|
||||
t.Cleanup(extension.ResetCompiled)
|
||||
extension.RegisterCompiled(extension.InProc{ID: "demo", Register: func(reg extension.Registry) error {
|
||||
if err := reg.AddCommandHandler(json.RawMessage(`{"id":"demo.one","title":"一"}`), func(ctx context.Context, h extension.Host, req extension.Request) (any, error) {
|
||||
return "ok", nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// 重复 id 注册失败:总线拒绝,摘要不记录。
|
||||
if err := reg.AddCommand(json.RawMessage(`{"id":"demo.one","title":"重复"}`)); err == nil {
|
||||
t.Fatal("duplicate command should fail")
|
||||
}
|
||||
if err := reg.AddIntent(json.RawMessage(`{"id":"demo.go","phrases":["演示"],"command":"demo.one"}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = reg.AddSkill("demo-skill", "# 演示\n技能正文")
|
||||
return nil
|
||||
}})
|
||||
extension.RegisterCompiled(extension.InProc{ID: "broken", Register: func(reg extension.Registry) error {
|
||||
if err := reg.AddCommand(json.RawMessage(`{"id":"broken.cmd","title":"先成功"}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
return reg.AddCommandHandler(json.RawMessage(`not-json`), nil)
|
||||
}})
|
||||
// 无 Register 的登记不产生条目。
|
||||
extension.RegisterCompiled(extension.InProc{ID: "empty"})
|
||||
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
bus := cmdbus.New(st, nil, func() time.Time { return time.Unix(1700000000, 0).UTC() })
|
||||
sums := applyCompiled(t.TempDir(), bus, hostcaps{st: st, set: settings.NewService(st)})
|
||||
if len(sums) != 2 {
|
||||
t.Fatalf("want 2 summaries, got %d: %+v", len(sums), sums)
|
||||
}
|
||||
if sums[0].ID != "demo" {
|
||||
t.Fatalf("sums[0] = %+v", sums[0])
|
||||
}
|
||||
if len(sums[0].Commands) != 1 || sums[0].Commands[0] != "demo.one" {
|
||||
t.Fatalf("demo commands = %v", sums[0].Commands)
|
||||
}
|
||||
if len(sums[0].Intents) != 1 || sums[0].Intents[0] != "demo.go" {
|
||||
t.Fatalf("demo intents = %v", sums[0].Intents)
|
||||
}
|
||||
if len(sums[0].Skills) != 1 || sums[0].Skills[0] != "demo-skill" {
|
||||
t.Fatalf("demo skills = %v", sums[0].Skills)
|
||||
}
|
||||
// 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])
|
||||
}
|
||||
// 摘要与总线一致:记录的命令真实可分发(不是空头衔)。
|
||||
resp := bus.Dispatch(context.Background(), cmdbus.Request{ID: "demo.one", Owner: "alice", Entrance: "test"})
|
||||
if resp.Receipt.State != agentcommand.StateCommitted {
|
||||
t.Fatalf("demo.one dispatch = %s (%s)", resp.Receipt.State, resp.Receipt.Reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"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"
|
||||
"agent-system-backend/pkg/extension"
|
||||
)
|
||||
|
||||
type compiledListView struct {
|
||||
Manifest struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Provides struct {
|
||||
Commands []string `json:"commands"`
|
||||
Intents []string `json:"intents"`
|
||||
} `json:"provides"`
|
||||
Backend struct {
|
||||
Mode string `json:"mode"`
|
||||
} `json:"backend"`
|
||||
} `json:"manifest"`
|
||||
}
|
||||
|
||||
// WP3-I4:/api/plugins 合并编译插件条目——backend.mode=inproc、apiVersion 取
|
||||
// 扩展面版本、provides 来自注册面摘要;目录插件同名时目录条目权威不重复。
|
||||
func TestListPluginsMergesCompiled(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{})
|
||||
|
||||
pluginsRoot := filepath.Join(dir, "plugins")
|
||||
// 目录插件与编译条目同名:目录条目权威,编译合成条目不重复列出。
|
||||
if err := os.MkdirAll(filepath.Join(pluginsRoot, "generative-ui"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"id":"generative-ui","name":"目录版","version":"1.2.3","apiVersion":"1.0","provides":{}}`
|
||||
if err := os.WriteFile(filepath.Join(pluginsRoot, "generative-ui", "plugin.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ph := plugins.New(st, reg, setSvc, pluginsRoot)
|
||||
for id, err := range ph.LoadDir() {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadDir %s: %v", id, err)
|
||||
}
|
||||
}
|
||||
|
||||
srv := New(st, mgr, reg, profSvc, setSvc, ph)
|
||||
srv.Auth = NewAuthenticator(st, true)
|
||||
srv.CompiledPlugins = []CompiledPlugin{
|
||||
{ID: "rdm", Commands: []string{"rdm.ingest", "rdm.inspect.capabilities"}, Intents: []string{"rdm.write-proposal"}},
|
||||
{ID: "generative-ui", Commands: []string{"generative-ui.present"}},
|
||||
}
|
||||
h := srv.Handler()
|
||||
key, err := st.CreateAPIKey("alice", "e2e")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/plugins", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
var list []compiledListView
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var rdm, genui *compiledListView
|
||||
genuiCount := 0
|
||||
for i := range list {
|
||||
switch list[i].Manifest.ID {
|
||||
case "rdm":
|
||||
rdm = &list[i]
|
||||
case "generative-ui":
|
||||
genui = &list[i]
|
||||
genuiCount++
|
||||
}
|
||||
}
|
||||
if rdm == nil {
|
||||
t.Fatalf("compiled rdm entry missing: %s", rec.Body.String())
|
||||
}
|
||||
if rdm.Manifest.Name != "rdm" || rdm.Manifest.Backend.Mode != "inproc" {
|
||||
t.Fatalf("rdm entry = %+v", rdm.Manifest)
|
||||
}
|
||||
if rdm.Manifest.APIVersion != extension.SurfaceVersion {
|
||||
t.Fatalf("rdm apiVersion = %q, want %q", rdm.Manifest.APIVersion, extension.SurfaceVersion)
|
||||
}
|
||||
if len(rdm.Manifest.Provides.Commands) != 2 || len(rdm.Manifest.Provides.Intents) != 1 {
|
||||
t.Fatalf("rdm provides = %+v", rdm.Manifest.Provides)
|
||||
}
|
||||
if genuiCount != 1 || genui == nil {
|
||||
t.Fatalf("generative-ui listed %d times", genuiCount)
|
||||
}
|
||||
// 目录条目权威:同名编译条目不重复、manifest 不被合成版覆盖。
|
||||
if genui.Manifest.Name != "目录版" || genui.Manifest.Version != "1.2.3" || genui.Manifest.Backend.Mode != "" {
|
||||
t.Fatalf("genui entry = %+v", genui.Manifest)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import (
|
||||
"agent-system-backend/internal/surfacesvc"
|
||||
"agent-system-backend/internal/tools"
|
||||
"agent-system-backend/internal/workflow"
|
||||
"agent-system-backend/pkg/extension"
|
||||
"agent-system-backend/web"
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -70,6 +71,9 @@ type Server struct {
|
||||
// 默认拒绝——旧只读适配器不得自动获得写入能力。公开契约的版本化扩展
|
||||
// 计划见 docs/guides/team-storage.md(KTD13 一节)。
|
||||
ProjectAuthzActions func(owner string, projectID core.ID, action ProjectAction) (bool, error)
|
||||
// CompiledPlugins 编译进二进制的进程内插件摘要(组合根在 applyCompiled
|
||||
// 期间记录注册面生成;nil/空 = 无编译插件,清单行为不变)。
|
||||
CompiledPlugins []CompiledPlugin
|
||||
}
|
||||
|
||||
// ProjectAction 项目资源的动作授权矩阵(KTD13)。共享可读不等于可写或可
|
||||
@@ -1448,6 +1452,16 @@ func (s *Server) deleteUserTool(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// ---- 插件 ----
|
||||
|
||||
// CompiledPlugin 是一个编译进二进制的进程内插件的注册面摘要(WP3-I4):
|
||||
// ID 即插件身份;Commands/Intents/Skills 为装载期成功注册的声明资产名。
|
||||
// 命令规格与处理器在命令总线,摘要只服务 /api/plugins 管理清单展示。
|
||||
type CompiledPlugin struct {
|
||||
ID string
|
||||
Commands []string
|
||||
Intents []string
|
||||
Skills []string
|
||||
}
|
||||
|
||||
type pluginView struct {
|
||||
Manifest core.PluginManifest `json:"manifest"`
|
||||
Panels []core.PluginPanel `json:"panels"`
|
||||
@@ -1463,13 +1477,28 @@ type pluginView struct {
|
||||
|
||||
func (s *Server) listPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
var out []pluginView
|
||||
dirIDs := map[string]bool{}
|
||||
for _, ld := range s.plugins.ListFor(s.owner(r), r.URL.Query().Get("projectId")) {
|
||||
dirIDs[ld.Manifest.ID] = true
|
||||
out = append(out, pluginView{
|
||||
Manifest: ld.Manifest, Panels: ld.Panels, Cards: ld.Cards,
|
||||
Workflows: ld.Workflows, Intents: ld.Intents, Prompts: ld.Prompts, Settings: ld.PluginSettings,
|
||||
Pages: ld.Pages, Channels: ld.Channels, Skills: ld.Skills,
|
||||
})
|
||||
}
|
||||
// 编译插件(WP3-I4):条目由注册面合成——backend.mode=inproc 标记编译
|
||||
// 来源,apiVersion 取扩展面版本;与目录插件同名时目录 manifest 权威
|
||||
// (目录版本可装卸、信息更全),编译条目不重复列出。
|
||||
for _, cp := range s.CompiledPlugins {
|
||||
if dirIDs[cp.ID] {
|
||||
continue
|
||||
}
|
||||
out = append(out, pluginView{Manifest: core.PluginManifest{
|
||||
ID: cp.ID, Name: cp.ID, APIVersion: extension.SurfaceVersion,
|
||||
Provides: core.PluginProvides{Commands: cp.Commands, Intents: cp.Intents, Skills: cp.Skills},
|
||||
Backend: core.PluginBackend{Mode: "inproc"},
|
||||
}})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, orEmptySlice(out))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user