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:
AskaEth committed 2026-10-06 14:12:23 +08:00
1 parent a150211468
commit 2e1c3ed3fd
13 files changed
+2043 -204

No files matched your search

+188
View File
@@ -0,0 +1,188 @@
package main
// 本文件是**内置插件动作面的登记入口**(design/09 §5.2.1「动作分发」;切片 L1/PLG-2 的
// 执行面接线):把编译进二进制的插件声明的动作处理器登记到插件管理器上。首个使用者是
// web-search 的「搜索提供商」页(§5.5 dogfood):四个动作
// plugin.listProviders / plugin.saveProvider / plugin.testProvider / plugin.reauthProvider,
// 外加下拉候选来源 `plugin:providers`(前端按 `optionsFrom` 推出这个名字)。
//
// 三个文件的分工(都在本包):
// - 本文件:登记入口、处理器环境、动作名映射、「声明了却没实现」的装配期核对;
// - `websearch_action_data.go`:五个动作的业务数据面(可变源的读写与翻译、载荷解析);
// - `websearch_probe.go`:探针的传输层绑定(HTTP 客户端 + 同主机守卫 + 状态映射)。
//
// 三条边界:
//
// 1. **数据面只读写可变源**:`$DATA_DIR/config/plugins/web-search.json`(design/13 §2.1),
// 经 runtimeconfig 的读写入口(0600 + temp+rename),宿主自己不开新路径、不猜形状;
// 2. **探针真发一次请求**:走 `search.Probe`(同一家 provider 构造、同一份归一化),
// 不靠「配置看起来齐了」假装连通(§5.5 的「测试连通」);
// 3. **凭据不出后端**:读侧只回掩码(`MaskedWebSearchFile`),日志只记 provider 名与
// 结果码,绝不记 key / 地址。
//
// 本片不做的事(如实登记,见报告 §未做):不热替换**运行中**的 web-search 工具 ——
// 面板保存后工具仍用启动期解析出的 provider 接线,改动在下次重启生效。
import (
"log/slog"
"sort"
"strings"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// actionWireLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
const actionWireLogSource = "cmd/cyrene/wire_plugin_actions"
// 动作失败码(宿主级,且只在动作面这组文件里使用)。领域自己的码来自 search(探针码)
// 与 pluginmgr.ActionCodeInvalidPayload。
const (
// actionCodeUnknownProvider 报告请求点名了一家本期没登记的 provider。
actionCodeUnknownProvider = "unknown_provider"
// actionCodeConfigUnreadable 报告面板配置存在但读不出来(损坏/权限)。
actionCodeConfigUnreadable = "config_unreadable"
// actionCodeConfigWriteFailed 报告面板配置写不进去(磁盘/权限)。
actionCodeConfigWriteFailed = "config_write_failed"
)
// wireBuiltinPluginActions 把内置插件的动作处理器登记到插件管理器上(§5.2.1:
// 插件在组合根注册自己的动作处理器)。
//
// 登记之后旧账要结清:**声明里写了但没人实现**的动作会如实记一条 WARN(端点对它的
// 结算就是 501)。这正是 §5.2.1「表里写了一个没实现的动作,review 时一眼可见」的
// 运行期版本 —— 装配期就把差异说出来,而不是等用户点出一个 501。
//
// 登记失败(命名空间非法 / 中枢未接线)只记 ERROR、不阻断启动:一个坏的动作面不该
// 让整个后端起不来(与面板声明的处置一致:该面板不注册 + 显式报错)。
func wireBuiltinPluginActions(mgr *pluginmgr.Manager, hub *pluginmgr.Panels, a *app, log *slog.Logger) {
if mgr == nil || hub == nil {
return
}
env, ok := a.webSearchActionEnv(log)
if !ok {
log.Warn("builtin plugin actions not registered: no config/data dir",
"pluginId", search.PanelPluginID, "effect", "panel actions will answer 501")
return
}
handlers := webSearchActionHandlers(env)
for optionsFrom, handler := range webSearchOptionsHandlers(env) {
name := pluginmgr.OptionsActionName(optionsFrom)
if name == "" {
log.Error("optionsFrom 无法映射成动作名,候选来源未接线",
"pluginId", search.PanelPluginID, "optionsFrom", optionsFrom)
continue
}
handlers[name] = handler
}
if err := mgr.RegisterActions(search.PanelPluginID, handlers); err != nil {
log.Error("builtin plugin actions not registered; panel actions will answer 501",
"pluginId", search.PanelPluginID, "error", err)
return
}
log.Info("builtin plugin actions registered",
"pluginId", search.PanelPluginID, "actions", registeredActionNames(hub))
warnImpliedButMissing(hub, log)
}
// warnImpliedButMissing 把「声明里有、登记里没有」的动作与候选来源如实报出来
// (前者的结算就是 501 action_not_implemented;后者会让前端静默降级成文本框)。
func warnImpliedButMissing(hub *pluginmgr.Panels, log *slog.Logger) {
registered := make(map[string]bool)
for _, name := range hub.ActionHandlerNames(search.PanelPluginID) {
registered[name] = true
}
for _, name := range hub.DeclaredActions(search.PanelPluginID) {
if !registered[name] {
log.Warn("declared plugin action has no handler; it will answer 501",
"pluginId", search.PanelPluginID, "action", name)
}
}
for _, src := range declaredOptionsSources(search.AdminPanels()) {
name := pluginmgr.OptionsActionName(src)
if name == "" || !registered[name] {
log.Warn("declared optionsFrom has no handler; the control degrades to a text input",
"pluginId", search.PanelPluginID, "optionsFrom", src, "action", name)
}
}
}
// registeredActionNames 返回已登记的处理器的动作名(诊断日志用,已排序)。
func registeredActionNames(hub *pluginmgr.Panels) []string {
names := hub.ActionHandlerNames(search.PanelPluginID)
sort.Strings(names)
return names
}
// declaredOptionsSources 从声明里收集全部 `optionsFrom` 取值(去重、排序)。
//
// 用途是**反向核对**:声明里出现了一个没有实现的候选来源时,装配期就要说出来 ——
// 否则前端只会静默降级成文本框,没人知道是「声明多写了一个」。
func declaredOptionsSources(panels []sdk.Panel) []string {
seen := make(map[string]bool)
for _, panel := range panels {
for _, s := range panel.Sections {
for _, f := range s.Fields {
if key := strings.TrimSpace(f.OptionsFrom); key != "" {
seen[key] = true
}
}
}
}
out := make([]string, 0, len(seen))
for key := range seen {
out = append(out, key)
}
sort.Strings(out)
return out
}
// webSearchActionEnv 是 web-search 动作处理器共享的接线事实(一次装配、多动作复用)。
//
// 刻意只装**在多个动作间共享**的东西(日志出口、可变源路径、`.env` 解析后的配置),
// 每次调用各自读文件:动作之间不共享「当前配置」这份可变状态,就不会出现
// 「A 动作读到的是 B 动作刚改之前的那份」。
type webSearchActionEnv struct {
log *slog.Logger
// path 是可变源落点 `$DATA_DIR/config/plugins/web-search.json`。
path string
// envCfg 是 `.env` 解析结果(**只用来取代码默认值**:各云家的默认基地址与超时)。
// 把面板没写的字段落回代码默认值,与工具运行时的 `orDefault` 口径一致。
envCfg config.WebSearchConfig
}
// webSearchActionEnv 构造处理器环境。ok=false 表示没有配置/数据目录
// (未装配完成或测试夹具),此时不登记动作,由调用方如实告警。
func (a *app) webSearchActionEnv(log *slog.Logger) (webSearchActionEnv, bool) {
if a.cfg == nil {
return webSearchActionEnv{}, false
}
return webSearchActionEnv{
log: log,
path: runtimeconfig.WebSearchConfigPath(resolveDataDir(a.cfg)),
envCfg: a.cfg.WebSearch,
}, true
}
// webSearchActionHandlers 返回 web-search 插件声明的**四个动作**的处理器。
func webSearchActionHandlers(env webSearchActionEnv) map[string]pluginmgr.ActionHandler {
return map[string]pluginmgr.ActionHandler{
search.ActionListProviders: env.listProviders,
search.ActionSaveProvider: env.saveProvider,
search.ActionTestProvider: env.testProvider,
search.ActionReauthProvider: env.reauthProvider,
}
}
// webSearchOptionsHandlers 返回「下拉候选来源」的处理器(键 = 声明里的 `optionsFrom` 取值)。
//
// 映射成什么动作名由 `pluginmgr.OptionsActionName` 一处决定(与前端
// `resolveOptionsAction` 同源):两处若分叉,声明的下拉就会去调一个宿主不认识的名字。
func webSearchOptionsHandlers(env webSearchActionEnv) map[string]pluginmgr.ActionHandler {
return map[string]pluginmgr.ActionHandler{
"plugin:providers": env.listProviderOptions,
}
}