Files
Cyrene_v2/internal/capability/plugin/actions.go
T
AskaEth 2e1c3ed3fd 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 补)。
2026-10-06 14:12:23 +08:00

191 lines
8.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}