feat(plugins): 面板动作执行器接线 —— 动作真可用,不再 501 兜底(L1)
- 插件管理器加生产动作处理器登记面(RegisterActions);未登记处理器/未声明动作仍 404 - web-search 四个动作真读写真源 + 真发 HTTP 探针:plugin.listProviders/saveProvider/ testProvider/reauthProvider(配置落 $DATA_DIR/config/plugins/web-search.json,0600) - 失败回结构化错误码(不含上游原文)+ 审计带 code 字段;501 文案改为「未登记处理器」 - 拆出 panelactions.go(动作索引推导/OptionsActionName/三闸门分发),panels.go 519→347 行 缺口(如实):面板保存后运行中的工具**不热重载**(下一片做);声明面缺 primary/fallback (按 DECISIONS-2026-10-06 #1/#2 补)。
This commit is contained in:
1 parent
a150211468
commit
2e1c3ed3fd
13 files changed
+2043
-204
No files matched your search
@@ -0,0 +1,190 @@
|
||||
package plugin
|
||||
|
||||
// 本文件是 design/09-插件生态 §5.2.1「动作分发」一栏在**插件管理器侧的落点**(切片
|
||||
// L1/PLG-2 的执行面接线):动作处理器的**注册**(谁执行哪个动作)与失败的**结构化形状**。
|
||||
//
|
||||
// 三条口径:
|
||||
//
|
||||
// 1. **注册面而不是全局执行器**(§5.2.1 B「插件管理器 = 唯一中枢」):插件在组合根把
|
||||
// 自己的动作处理器登记到插件管理器上;登记是可查的(谁登记了什么),未登记的插件
|
||||
// 互不干扰 —— 用一个全局 ActionExecutor 会让「哪个插件负责这个名字」变成不可证的约定。
|
||||
// 2. **声明仍是唯一闸门**:登记处理器**不**等于可执行。Dispatch 先查声明(`Action`),
|
||||
// 未声明一律 404 —— 处理器的存在不会让一个没声明的名字变得可调用(纵深不变)。
|
||||
// 3. **失败要能分类**:ActionError 把「错误码 / 文案 / HTTP 状态」一起交出去
|
||||
// (§5.4.6 错误码与文案分离,前端据此本地化),绝不靠解析字符串把病因猜出来。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ActionHandler 执行一个插件的**已声明动作**。
|
||||
//
|
||||
// 与调用方的分工:宿主端点(网关)负责取调用者身份、角色门与审计;登记进来的处理器
|
||||
// 只负责「这个名字要做什么」。处理器**不得**去发起插件提供的任意 URL(§5.4.4:动作面
|
||||
// 只有名字),也不得把凭据写进返回值或日志(§5.4.2)。
|
||||
type ActionHandler func(ctx context.Context, req ActionRequest) (any, error)
|
||||
|
||||
// ErrInvalidActionRegistration 报告一次动作处理器登记本身不合法(命名空间/名字/处理器)。
|
||||
// 它是**接线错误**(组合根写错了),不是运行期错误:调用方应当把它当成启动期问题处理。
|
||||
var ErrInvalidActionRegistration = errors.New("plugin: invalid action registration")
|
||||
|
||||
// ActionCodeInvalidPayload 是宿主级的通用失败码:请求体形状不对(缺字段 / 类型错)。
|
||||
// 领域自己的失败码(如搜索的 unknown_provider)由插件实现定义,不集中在这里。
|
||||
const ActionCodeInvalidPayload = "invalid_payload"
|
||||
|
||||
// ActionError 是动作处理器交出的**结构化失败**(错误码 + 文案 + 目标 HTTP 状态)。
|
||||
//
|
||||
// 为什么由插件侧给出状态码:只有实现知道这次失败是「调用方写错了请求」(400)、
|
||||
// 「上游不可用」(502)还是「宿主没接线」(501,由哨兵错误表达)。网关只做透传,
|
||||
// 不替实现猜病因(§5.4.6)。
|
||||
type ActionError struct {
|
||||
// Code 是稳定的机器可读错误码(前端按它本地化,不解析文案)。
|
||||
Code string
|
||||
// Message 是给人看的一句话(**绝不含凭据、绝不含上游原始报文**)。
|
||||
Message string
|
||||
// Status 是这次失败该用的 HTTP 状态;0 表示由网关按默认(502)处理。
|
||||
Status int
|
||||
// Err 是底层病因(可选)。它只进服务端日志,不进响应体。
|
||||
Err error
|
||||
}
|
||||
|
||||
// NewActionError 构造一个结构化动作失败。
|
||||
func NewActionError(code, message string, status int, err error) *ActionError {
|
||||
return &ActionError{Code: code, Message: message, Status: status, Err: err}
|
||||
}
|
||||
|
||||
// Error 实现 error。格式把码、文案与病因串起来,只为日志可读;
|
||||
// 客户端拿到的是 Code/Message 两个字段(不解析这句话)。
|
||||
func (e *ActionError) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Err != nil {
|
||||
return fmt.Sprintf("%s: %s: %v", e.Code, e.Message, e.Err)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", e.Code, e.Message)
|
||||
}
|
||||
|
||||
// Unwrap 让 errors.Is/As 能穿到病因(例如 runtimeconfig.ErrInvalidWebSearch)。
|
||||
func (e *ActionError) Unwrap() error {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Err
|
||||
}
|
||||
|
||||
// AsActionError 取出一条错误链里的结构化动作失败(没有则 false)。
|
||||
// 网关用它把「插件侧自己说清了病因」与「未知的内部错误」分开映射。
|
||||
func AsActionError(err error) (*ActionError, bool) {
|
||||
var ae *ActionError
|
||||
if !errors.As(err, &ae) || ae == nil {
|
||||
return nil, false
|
||||
}
|
||||
return ae, true
|
||||
}
|
||||
|
||||
// RegisterActions 为一个插件登记它的动作处理器(覆盖同名)。
|
||||
//
|
||||
// pluginID 必须能当面板目录键(`PanelIDValid`):动作的命名空间就是面板目录的命名空间,
|
||||
// 一个没有合法键的插件不可能被端点寻址到,登记它就是登记一个永远调不到的名字。
|
||||
//
|
||||
// 名字为空 / 处理器为 nil 一律拒(静默忽略会让「点了没反应」重新变成一个查不出的谜)。
|
||||
// 首次调用会整体替换该插件的处理器表,之后再次调用是**前缀合并**(同名覆盖)——
|
||||
// 插件可以分两步登记自己的动作面。
|
||||
//
|
||||
// 允许登记一个**当前还没声明**的动作名:它暂时不可达(Dispatch 先查声明),
|
||||
// 但会记一条 WARN 让「登记了却永远不会触发」当场可见。声明与登记的先后顺序在
|
||||
// 组合根里不总是可控(热装插件的声明来自 manifest,可能晚于处理器注册)。
|
||||
func (p *Panels) RegisterActions(pluginID string, handlers map[string]ActionHandler) error {
|
||||
if p == nil {
|
||||
return fmt.Errorf("plugin: nil panel hub")
|
||||
}
|
||||
pluginID = strings.TrimSpace(pluginID)
|
||||
if !PanelIDValid(pluginID) {
|
||||
return fmt.Errorf("%w: %q 不能当面板目录键(动作没有所属命名空间)",
|
||||
ErrInvalidActionRegistration, pluginID)
|
||||
}
|
||||
if len(handlers) == 0 {
|
||||
return fmt.Errorf("%w: %s 没有给出任何处理器", ErrInvalidActionRegistration, pluginID)
|
||||
}
|
||||
clean := make(map[string]ActionHandler, len(handlers))
|
||||
for name, h := range handlers {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return fmt.Errorf("%w: %s 里有空动作名", ErrInvalidActionRegistration, pluginID)
|
||||
}
|
||||
if h == nil {
|
||||
return fmt.Errorf("%w: %s/%s 的处理器是 nil", ErrInvalidActionRegistration, pluginID, name)
|
||||
}
|
||||
clean[name] = h
|
||||
}
|
||||
|
||||
p.mu.Lock()
|
||||
if p.handlers == nil {
|
||||
p.handlers = make(map[string]map[string]ActionHandler)
|
||||
}
|
||||
if p.handlers[pluginID] == nil {
|
||||
p.handlers[pluginID] = make(map[string]ActionHandler, len(clean))
|
||||
}
|
||||
declared := actionIndex(p.decls[pluginID])
|
||||
for name, h := range clean {
|
||||
p.handlers[pluginID][name] = h
|
||||
}
|
||||
p.mu.Unlock()
|
||||
|
||||
warnUndeclaredHandlers(p.log, pluginID, clean, declared)
|
||||
return nil
|
||||
}
|
||||
|
||||
// warnUndeclaredHandlers 对「登记了但当前没有声明」的动作记一条 WARN(见 RegisterActions)。
|
||||
// 名字排序后再报:日志顺序稳定,测试与排查都不靠 map 迭代顺序。
|
||||
func warnUndeclaredHandlers(log *slog.Logger, pluginID string, handlers map[string]ActionHandler, declared map[string]ActionKind) {
|
||||
missing := make([]string, 0, len(handlers))
|
||||
for name := range handlers {
|
||||
if _, ok := declared[name]; !ok {
|
||||
missing = append(missing, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(missing)
|
||||
for _, name := range missing {
|
||||
log.Warn("action handler has no declaration yet; it stays unreachable until declared",
|
||||
"pluginId", pluginID, "action", name)
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterActions 是 *Manager 上的动作登记入口(§5.2.1「插件在组合根注册自己的动作
|
||||
// 处理器」):它把登记转交给已接线的面板中枢,中枢未接线时报错 —— 绝不静默吞掉,
|
||||
// 否则组合根会以为「动作接好了」而端点仍在 501。
|
||||
func (m *Manager) RegisterActions(pluginID string, handlers map[string]ActionHandler) error {
|
||||
if m == nil {
|
||||
return fmt.Errorf("plugin: nil manager")
|
||||
}
|
||||
hub := m.Panels()
|
||||
if hub == nil {
|
||||
return fmt.Errorf("%w: %s(面板中枢未接线,动作登记无处落)",
|
||||
ErrInvalidActionRegistration, pluginID)
|
||||
}
|
||||
return hub.RegisterActions(pluginID, handlers)
|
||||
}
|
||||
|
||||
// ActionHandlerNames 返回某插件**已登记处理器**的动作名(已排序,面向诊断与测试)。
|
||||
// 它与 DeclaredActions 是两个不同的事实:前者是「谁能执行」,后者是「声明里有什么」。
|
||||
// 两者的交集才是真正可调用的动作(Dispatch 的口径)。
|
||||
func (p *Panels) ActionHandlerNames(pluginID string) []string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
out := make([]string, 0, len(p.handlers[pluginID]))
|
||||
for name := range p.handlers[pluginID] {
|
||||
out = append(out, name)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user