feat(search): provider 热重载 + primary/fallback(W3)

- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool
  窄方法,保留运行期 Policy);下一轮即生效,不要求重启
- 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable
- 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填)
- 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷)
- 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
This commit is contained in:
AskaEth committed 2026-10-06 15:59:12 +08:00
1 parent 5ee5f9126f
commit a30f4a49ff
12 files changed
+1696 -16

No files matched your search

+406
View File
@@ -0,0 +1,406 @@
package main
// 本文件钉住切片 W3 的**热重载**(`DECISIONS-2026-10-06` 裁决 #1):
//
// - 面板保存 ⇒ 重建 web-search 工具 ⇒ 替换注册表条目 ⇒ **下一轮即生效**
// (design/09 §5.5 的端到端判据:不改 `.env`、不重启,下一次搜索真的走新配置);
// - 主 provider / 降级链经同一个表单落到文件级字段后**立刻接上工具**;
// - 保存结果会让后端**下次起不来**时:非 2xx + 结构化码 + **按字节回滚**;
// - 用户真正拿到的那一面(管理端点 HTTP)也逐条核对。
//
// 夹具与假上游在 websearch_testenv_test.go;面板选择字段与解析口径在
// websearch_selection_test.go。
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// --- ① 保存后热重载:下一次搜索真的走新配置 -------------------------------
// TestWebSearchHotReloadPointsAtSavedEndpoint 是热重载的主判据(design/09 §5.5):
// 面板把 searxng 的地址改成另一个上游 ⇒ **不重启** ⇒ 下一轮搜索必须打到新那家,
// 且旧那家不再收到请求。同时钉住「注册表里的值是真的换了一把」(不是改了旧对象)。
func TestWebSearchHotReloadPointsAtSavedEndpoint(t *testing.T) {
old := newWSFakeSearxng(t, "old")
fresh := newWSFakeSearxng(t, "fresh")
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_ENDPOINT="+old.url,
)
if _, err := env.search(t, "cyrene"); err != nil {
t.Fatalf("启动期搜索失败: %v", err)
}
if len(old.hits) == 0 {
t.Fatal("启动期没打到配置里的上游 —— 接线本身有问题")
}
before := env.toolValue(t)
old.drain()
outcome := env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": fresh.url, "enabled": true,
})
if !outcome.Applied || !outcome.Registered {
t.Fatalf("hotReload = %+v, want applied 且 registered", outcome)
}
if env.toolValue(t) == before {
t.Fatal("注册表里的 web-search 还是启动期那一把(没被替换)")
}
got, err := env.search(t, "cyrene")
if err != nil {
t.Fatalf("保存后搜索失败: %v", err)
}
if len(fresh.hits) == 0 {
t.Fatal("保存后的搜索没打到新上游 —— 改动没生效(还得重启)")
}
old.noHit(t)
if got["provider"] != search.NameSearXNG {
t.Fatalf("provider = %v, want %q", got["provider"], search.NameSearXNG)
}
}
// TestWebSearchHotReloadKeepsCapabilityPolicy:热替换必须**保留**能力面策略
// (分组/陌生人可达标记是运行期配置,不属于工具实现)。丢了它 = 一次面板保存让某个群的
// 工具面悄悄缩水。
func TestWebSearchHotReloadKeepsCapabilityPolicy(t *testing.T) {
up := newWSFakeSearxng(t, "a")
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_ENDPOINT="+up.url,
)
if err := env.app.caps.SetAllowInStranger(webSearchToolName, true); err != nil {
t.Fatalf("SetAllowInStranger: %v", err)
}
env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
})
pol, err := env.app.caps.Policy(webSearchToolName)
if err != nil {
t.Fatalf("Policy: %v", err)
}
if !pol.AllowInStranger {
t.Fatal("热替换丢掉了 AllowInStranger(策略是运行期配置,必须沿用)")
}
}
// --- ② 主 provider / 降级链:面板可配且立刻生效 -----------------------------
// TestWebSearchHotReloadSwitchesPrimaryAndFallback 钉住裁决 #2 的端到端语义:
// 面板选中谁当主家,下一次搜索就走谁;主家不可用时按降级链前进并**如实标注**实际应答的家。
func TestWebSearchHotReloadSwitchesPrimaryAndFallback(t *testing.T) {
searxng := newWSFakeUpstream(t, http.StatusInternalServerError, `{"error":"boom"}`)
bocha := newWSFakeUpstream(t, http.StatusOK,
`{"webPages":{"value":[{"name":"博查结果","url":"https://b.example/1","summary":"正文"}]}}`)
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_ENDPOINT="+searxng.url,
"WEB_SEARCH_BOCHA_API_KEY=sk-bocha",
"WEB_SEARCH_BOCHA_BASE_URL="+bocha.url,
)
// 选 bocha 当主家(文件级 primary),降级链留 searxng:两者都在盘上、都已配齐。
outcome := env.mustSave(t, map[string]any{
"provider": search.NameBocha, "apiKey": "sk-bocha", "baseUrl": bocha.url, "enabled": true,
"primary": search.NameBocha,
"fallback": []any{search.NameSearXNG},
})
if !outcome.Applied {
t.Fatalf("hotReload = %+v, want applied", outcome)
}
got, err := env.search(t, "cyrene")
if err != nil {
t.Fatalf("切换主家后搜索失败: %v", err)
}
if got["provider"] != search.NameBocha {
t.Fatalf("provider = %v, want %q(面板选的主家必须立刻生效)", got["provider"], search.NameBocha)
}
if len(bocha.hits) == 0 {
t.Fatal("选中的新主家一个请求都没收到")
}
// 把主家换回 searxng(它 500)⇒ 必须**降级到 bocha** 并如实标注是谁答的。
bocha.drain()
searxng.drain()
env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": searxng.url, "enabled": true,
"primary": search.NameSearXNG,
"fallback": []any{search.NameBocha},
})
got, err = env.search(t, "cyrene")
if err != nil {
t.Fatalf("降级链搜索失败: %v", err)
}
if got["provider"] != search.NameBocha {
t.Fatalf("provider = %v, want %q(主力 500 时必须前进一格并如实标注)", got["provider"], search.NameBocha)
}
if len(searxng.hits) == 0 {
t.Fatal("降级链没先试主力 searxng")
}
}
// TestWebSearchHotReloadTogglesToolByPrimary 钉住「关掉就整把消失、再开就回来」:
// 主 provider 写成 off 哨兵 ⇒ 模型看不见这把工具(与启动期语义一致);改回一家可用的
// ⇒ 同一进程内它又回到注册面。
func TestWebSearchHotReloadTogglesToolByPrimary(t *testing.T) {
up := newWSFakeSearxng(t, "a")
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameSearXNG,
"WEB_SEARCH_ENDPOINT="+up.url,
)
off := env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
"primary": "off",
})
if !off.Applied || off.Registered {
t.Fatalf("hotReload = %+v, want applied=true registered=false", off)
}
if wsHasTool(env.app, webSearchToolName) {
t.Fatalf("主 provider 写成 off 后 web-search 不得留在注册面里: %v", toolNames(env.app))
}
back := env.mustSave(t, map[string]any{
"provider": search.NameSearXNG, "baseUrl": up.url, "enabled": true,
"primary": search.NameSearXNG,
})
if !back.Applied || !back.Registered {
t.Fatalf("hotReload = %+v, want applied=true registered=true", back)
}
if _, err := env.search(t, "cyrene"); err != nil {
t.Fatalf("改回可用主家后搜索应当成功: %v", err)
}
}
// --- ③ 回滚:不让面板存出一份「下次起不来」的配置 ---------------------------
// TestWebSearchHotReloadRollsBackUnstartableSave 是安全网的判据:面板把**当前主家**的
// 凭据清空后,这份配置会让后端下次启动直接失败(design/27 §3 的 fail-fast)⇒ 保存必须
// 诚实失败、把文件**按字节**回滚,并且运行中的工具必须仍是原来那一把。
func TestWebSearchHotReloadRollsBackUnstartableSave(t *testing.T) {
bocha := newWSFakeUpstream(t, http.StatusOK,
`{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`)
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameBocha,
"WEB_SEARCH_BOCHA_API_KEY=sk-keep",
"WEB_SEARCH_BOCHA_BASE_URL="+bocha.url,
)
before := env.rawFile(t)
toolBefore := env.toolValue(t)
_, err := env.save(map[string]any{"provider": search.NameBocha, "apiKey": ""})
ae, ok := pluginmgr.AsActionError(err)
if !ok {
t.Fatalf("err = %v,期望结构化 ActionError(清空主家凭据会让后端起不来)", err)
}
if ae.Code != actionCodeConfigUnstartable {
t.Fatalf("失败码 = %q, want %q", ae.Code, actionCodeConfigUnstartable)
}
if ae.Status < 400 || ae.Status > 599 {
t.Fatalf("失败状态 = %d,必须是非 2xx 的结构化失败", ae.Status)
}
if strings.Contains(ae.Message, "sk-keep") {
t.Fatalf("失败文案泄漏了凭据: %q", ae.Message)
}
if got := env.rawFile(t); !bytes.Equal(got, before) {
t.Fatalf("回滚后文件与保存前不逐字节相同:\nbefore=%s\nafter =%s", before, got)
}
if env.toolValue(t) != toolBefore {
t.Fatal("被回滚的保存却把注册表里的工具换掉了(盘上与内存不一致)")
}
got, err := env.search(t, "cyrene")
if err != nil {
t.Fatalf("回滚后搜索应当照常成功: %v", err)
}
if got["provider"] != search.NameBocha {
t.Fatalf("回滚后 provider = %v, want %q", got["provider"], search.NameBocha)
}
if out := env.out.String(); !strings.Contains(out, "rolled back") {
t.Fatalf("回滚必须留下可诊断的日志: %q", out)
}
}
// TestWebSearchReloadWithoutRegistryIsHonest:没有能力注册表(夹具 / 装配未完成)时,
// 热重载**如实报告 applied=false**,绝不假装换过了 —— 否则响应里的 registered 会是一句假话。
func TestWebSearchReloadWithoutRegistryIsHonest(t *testing.T) {
cfg := wsLoadConfig(t, "WEB_SEARCH_PROVIDER="+search.NameSearXNG)
b := bus.New()
defer b.Close()
mgr := pluginmgr.NewManager(registry.New(), filepath.Join(cfg.DataDir, "plugins.json"), testLog())
defer mgr.Close(context.Background())
// 故意不设 caps:这正是既有动作夹具(actionTestEnv)的形状。
a := &app{cfg: cfg, log: testLog(), bus: b, plugins: mgr}
wirePanelHub(a)
res, err := mgr.Panels().Dispatch(context.Background(), pluginmgr.ActionRequest{
PluginID: search.PanelPluginID, Action: search.ActionSaveProvider,
Payload: map[string]any{"values": map[string]any{
"provider": search.NameSearXNG, "baseUrl": "http://127.0.0.1:38471", "enabled": true,
}},
})
if err != nil {
t.Fatalf("保存失败: %v", err)
}
outcome, ok := res.(map[string]any)["hotReload"].(webSearchReloadOutcome)
if !ok {
t.Fatalf("响应里没有 hotReload: %#v", res)
}
if outcome.Applied || outcome.Registered {
t.Fatalf("没有注册表可换时必须如实报 applied=false: %+v", outcome)
}
}
// --- ④ 用户真正拿到的那一面:管理端点(HTTP)---------------------------------
// adminActionHTTP 把组合根装配出来的动作端点架起来,返回 base URL 与 owner 令牌。
//
// 网关这一段(鉴权 → 声明 → 分发 → 状态码映射)不是本片改的,但用户拿到的是它:
// 只在动作中枢上验,没法证明「契约里那个 409 真的到得了客户端」。
func adminActionHTTP(t *testing.T, env *reloadTestEnv) (string, string) {
t.Helper()
jwtSvc := auth.New(config.AuthConfig{JWTSecret: obsTestSecret})
gw, err := gateway.New(&config.Config{
Auth: config.AuthConfig{JWTSecret: obsTestSecret},
}, gateway.Deps{Log: testLog(), Auth: jwtSvc, Plugins: env.app.plugins})
if err != nil {
t.Fatalf("gateway.New: %v", err)
}
t.Cleanup(gw.Close)
ts := httptest.NewServer(gw.Handler())
t.Cleanup(ts.Close)
tok, err := jwtSvc.Sign("acct", auth.RoleOwner, time.Hour)
if err != nil {
t.Fatalf("sign token: %v", err)
}
return ts.URL + "/api/v1/admin/plugins/" + search.PanelPluginID + "/actions/" + search.ActionSaveProvider, tok
}
// TestWebSearchHotReloadOverAdminHTTP 核对成功路径的 HTTP 契约:200 信封里的
// `result.hotReload` 必须如实,而且保存后下一次搜索**真的**打到新上游。
func TestWebSearchHotReloadOverAdminHTTP(t *testing.T) {
cloud := newWSFakeUpstream(t, http.StatusOK,
`{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`)
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameBocha,
"WEB_SEARCH_BOCHA_API_KEY=sk-keep",
"WEB_SEARCH_BOCHA_BASE_URL="+cloud.url,
)
url, tok := adminActionHTTP(t, env)
status, raw := wsPostAction(t, url, tok, map[string]any{"values": map[string]any{
"provider": search.NameSearXNG, "baseUrl": cloud.url, "enabled": true,
"primary": search.NameBocha,
}})
if status != http.StatusOK {
t.Fatalf("保存状态 = %d, want 200(body=%s)", status, raw)
}
var body struct {
Data struct {
Action string `json:"action"`
Kind string `json:"kind"`
Result struct {
Saved bool `json:"saved"`
HotReload struct {
Applied bool `json:"applied"`
Registered bool `json:"registered"`
} `json:"hotReload"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("decode: %v (body=%s)", err, raw)
}
if body.Data.Kind != string(pluginmgr.ActionWrite) {
t.Errorf("kind = %q, want %q(写动作必须按写面鉴权)", body.Data.Kind, pluginmgr.ActionWrite)
}
if !body.Data.Result.Saved || !body.Data.Result.HotReload.Applied || !body.Data.Result.HotReload.Registered {
t.Fatalf("响应 envelope 里的结果不如实: %s", raw)
}
got, err := env.search(t, "cyrene")
if err != nil {
t.Fatalf("HTTP 保存后搜索失败: %v", err)
}
if got["provider"] != search.NameBocha {
t.Fatalf("provider = %v, want %q(HTTP 保存也必须热重载)", got["provider"], search.NameBocha)
}
}
// TestWebSearchSaveOverAdminHTTPRollsBackUnstartable 核对失败路径的 HTTP 契约:
// **非 2xx** + `{error:{code,message}}`,且盘上文件真的回到了保存前。
func TestWebSearchSaveOverAdminHTTPRollsBackUnstartable(t *testing.T) {
cloud := newWSFakeUpstream(t, http.StatusOK,
`{"webPages":{"value":[{"name":"博查","url":"https://b.example/1","summary":"正文"}]}}`)
env := newReloadTestEnv(t,
"WEB_SEARCH_PROVIDER="+search.NameBocha,
"WEB_SEARCH_BOCHA_API_KEY=sk-keep",
"WEB_SEARCH_BOCHA_BASE_URL="+cloud.url,
)
url, tok := adminActionHTTP(t, env)
before := env.rawFile(t)
status, raw := wsPostAction(t, url, tok, map[string]any{"values": map[string]any{
"provider": search.NameBocha, "apiKey": "",
}})
if status != http.StatusConflict {
t.Fatalf("状态 = %d, want 409(body=%s)", status, raw)
}
var body struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("decode error body: %v (body=%s)", err, raw)
}
if body.Error.Code != actionCodeConfigUnstartable {
t.Fatalf("错误码 = %q, want %q(body=%s)", body.Error.Code, actionCodeConfigUnstartable, raw)
}
if strings.Contains(body.Error.Message, "sk-keep") || body.Error.Message == "" {
t.Fatalf("错误文案 = %q(不得为空、不得含凭据)", body.Error.Message)
}
if got := env.rawFile(t); !bytes.Equal(got, before) {
t.Fatalf("HTTP 层的回滚不彻底:\nbefore=%s\nafter =%s", before, got)
}
}
// wsPostAction 向管理动作端点发一次 POST(Bearer + JSON 体),返回状态码与响应体。
func wsPostAction(t *testing.T, url, token string, payload any) (int, []byte) {
t.Helper()
raw, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(raw))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST %s: %v", url, err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read %s: %v", url, err)
}
return resp.StatusCode, body
}