Files
Cyrene_v2/cmd/cyrene/websearch_reload.go
AskaEth a30f4a49ff 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)
2026-10-06 15:59:12 +08:00

258 lines
12 KiB
Go
Raw Permalink 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 main
// 本文件是「搜索提供商」页的**热重载**(切片 W3;`DECISIONS-2026-10-06` 裁决 #1):
// 面板保存后,**重建** web-search 工具并**替换**能力注册表里的条目,使改动在下一轮对话
// 即生效 —— 不再要求「重启后端」。
//
// 为什么必须做(而不是把「重启后生效」写进面板文案):面板的意义就是「不改 `.env`、
// 不重启」,留着「重启后生效」等于把配置面做成摆设(裁决 #1 的理由原文)。design/09 §5.5
// 的端到端判据也正是「面板加一家 ⇒ 不改 `.env`、不重启 ⇒ 下一次搜索真的走它」。
//
// 三条口径:
//
// 1. **一次保存 = 一次事务**:写可变源 → 重建工具 → 替换注册表条目。任何一步失败都把
// 文件**回滚**到保存前那一份,绝不留「盘上是新的、内存是旧的」或「盘上的配置让后端
// 下次起不来」这两种半状态。
// 2. **重建走启动期同一条路**(`buildWebSearchTool` → `webSearchOptions`):重载后的工具
// 与启动期构造的逐字段同源,不是第二份接线逻辑(避免两处语义漂移)。
// 3. **替换是原子的、保留策略**:走 `registry.ReplaceTool` 而不是 Remove+Register
// (详见该方法注释:中间无空窗,且分组/陌生人可达标记不被重置)。
//
// 触发面只有一处:`wire_plugin_actions.go` 把**写可变源**的那个动作(`plugin.saveProvider`)
// 包上 `webSearchReloader.wrap`。动作本体(websearch_action_data.go 的 saveProvider)保持
// 「只负责把这条存进去」的单一职责,重载与回滚都在本文件。
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"sync"
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/logger"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
"git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search"
)
// webSearchReloadLogSource 是本文件日志的来源标签(项目规范 §10:禁止无来源裸日志)。
const webSearchReloadLogSource = "cmd/cyrene/websearch_reload"
// actionCodeConfigUnstartable 报告「这次保存的结果会让后端**下一次启动直接失败**」——
// 文件已回滚,改动**没有**生效(406 / 409 这类「与当前状态冲突」的档,不是 500 内部错)。
const actionCodeConfigUnstartable = "config_unstartable"
// webSearchRollbackFileMode 是回滚写回时的文件权限:与 runtimeconfig 落盘可变源的 0600
// 同口径(那份文件带 API Key)。硬编码成 0600 而不是读对方的常量,是因为那个常量未导出,
// 而变化方向是「收紧」,不存在比 0600 更松的风险。
const webSearchRollbackFileMode os.FileMode = 0o600
// webSearchReloadOutcome 报告一次热重载的结局,并作为动作响应里可机读的事实。
type webSearchReloadOutcome struct {
// Applied 报告这次保存**真的触发了**热重载(false = 没有能力注册表可换,
// 例如测试夹具;此时只落了盘,运行中的工具没动)。
Applied bool `json:"applied"`
// Registered 报告保存之后 web-search **在不在模型的工具面里**(Applied 为 true
// 时才有意义):关掉主 provider / 端点会让它整把消失,这是设计语义,不是失败。
Registered bool `json:"registered"`
}
// webSearchReloader 把「保存可变源 → 重建工具 → 换注册表条目」做成一次事务。
//
// 锁的粒度是**整次保存**(含动作本体那一步):两个并发的面板保存如果交错,可能出现
// 「A 的写 + B 的重载」这种盘上与内存对不上的组合。面板保存是低频动作,串行化的代价
// 可以忽略,换来的是「最后一次保存定终局」这条可证的顺序。
type webSearchReloader struct {
mu sync.Mutex
app *app
path string
log *slog.Logger
}
// webSearchActionHandlersWithHotReload 在既有的动作处理器表之上,给**写可变源**的动作
// 套上热重载事务(其余动作不动:`testProvider` / `reauthProvider` 不改文件,无需重载)。
//
// 包在 `wireBuiltinPluginActions` 里调用(登记入口一处决定「谁会被热重载」),因此
// 动作名到处理器的映射关系在这里仍然可见,不额外引入第二张表。
func webSearchActionHandlersWithHotReload(
env webSearchActionEnv, a *app, log *slog.Logger,
) map[string]pluginmgr.ActionHandler {
handlers := webSearchActionHandlers(env)
r := &webSearchReloader{app: a, path: env.path, log: logger.WithSource(log, webSearchReloadLogSource)}
for _, action := range []string{search.ActionSaveProvider} {
if inner, ok := handlers[action]; ok && inner != nil {
handlers[action] = r.wrap(inner)
}
}
return handlers
}
// wrap 把一次「写可变源的保存」变成事务:
//
// ① 快照文件(回滚的凭据);
// ② 跑动作本体(它负责把改动落盘);
// ③ 按**落盘后的配置**重建工具并替换注册表条目;
// ④ ③ 失败 ⇒ 回滚文件(内存没动过,因此回滚后两处一致)并回结构化错误。
func (r *webSearchReloader) wrap(inner pluginmgr.ActionHandler) pluginmgr.ActionHandler {
return func(ctx context.Context, req pluginmgr.ActionRequest) (any, error) {
r.mu.Lock()
defer r.mu.Unlock()
snap, err := r.snapshot()
if err != nil {
return nil, pluginmgr.NewActionError(actionCodeConfigUnreadable,
"面板配置读不出来,本次**没有**改动它;详见服务端日志", http.StatusInternalServerError, err)
}
res, err := inner(ctx, req)
if err != nil {
return nil, err
}
outcome, rerr := r.reload()
if rerr == nil {
return withReloadOutcome(res, outcome), nil
}
if rberr := r.rollback(snap); rberr != nil {
// 回滚都失败了 ⇒ 只能如实说「盘上可能已经变了」,绝不假装改动没发生。
r.log.Error("web-search config rollback failed after an unstartable save",
"path", r.path, "reloadError", rerr, "rollbackError", rberr)
return nil, pluginmgr.NewActionError(actionCodeConfigWriteFailed,
"这次改动会让 web-search 起不来,且回滚也失败了:请人工检查 "+
r.path+"(详见服务端日志)", http.StatusInternalServerError, errors.Join(rerr, rberr))
}
r.log.Warn("web-search panel save rolled back: the resulting config cannot start",
"path", r.path, "error", rerr)
return nil, pluginmgr.NewActionError(actionCodeConfigUnstartable,
unstartableMessage(rerr), http.StatusConflict, rerr)
}
}
// reload 按落盘后的配置重建工具并替换注册表条目。
//
// 三种结局:换掉(工具该在)/摘掉(当前配置下它不该在注册面里,与启动期语义一致)/
// 报错(这份配置起不来 ⇒ 调用方回滚)。没有能力注册表时如实报告 Applied=false。
func (r *webSearchReloader) reload() (webSearchReloadOutcome, error) {
if r.app == nil || r.app.cfg == nil || r.app.caps == nil {
// 没有注册表可换(测试夹具 / 装配未完成):只落了盘,工具没动。
// 不静默当成「换过了」——否则响应里的 registered 会是一句假话。
r.log.Debug("web-search hot reload skipped: no capability registry",
"path", r.path, "effect", "the running tool keeps the startup wiring")
return webSearchReloadOutcome{}, nil
}
tool, err := r.app.buildWebSearchTool(webSearchCausePanelSave)
if err != nil {
return webSearchReloadOutcome{}, err
}
if tool == nil {
return webSearchReloadOutcome{Applied: true}, r.app.removeSearchTool(r.log)
}
if err := r.app.replaceSearchTool(tool); err != nil {
return webSearchReloadOutcome{}, err
}
r.log.Info("web-search tool hot-reloaded after panel save",
"path", r.path, "action", search.ActionSaveProvider,
"effect", "the next turn uses the new wiring; no restart needed")
return webSearchReloadOutcome{Applied: true, Registered: true}, nil
}
// replaceSearchTool 把重建出来的工具放进注册面:**已登记就就地替换**(原子、保留策略),
// 之前不在注册面里(例如上一次保存把它关了)就新增。
func (a *app) replaceSearchTool(t sdk.Tool) error {
err := a.caps.ReplaceTool(t)
if err == nil {
return nil
}
if !errors.Is(err, registry.ErrNotFound) {
return fmt.Errorf("web-search: replace tool: %w", err)
}
if rerr := a.caps.RegisterTool(t); rerr != nil {
return fmt.Errorf("web-search: register tool: %w", rerr)
}
return nil
}
// removeSearchTool 把 web-search 从注册面摘掉(当前配置下它不该在:端点/主 provider 被
// 显式关闭,或选定 provider 没配齐)。本来就不在时是幂等的。
func (a *app) removeSearchTool(log *slog.Logger) error {
err := a.caps.Remove(webSearchToolName)
if err != nil && !errors.Is(err, registry.ErrNotFound) {
return fmt.Errorf("web-search: remove tool: %w", err)
}
log.Info("web-search tool removed from the registry after panel save",
"action", search.ActionSaveProvider,
"effect", "the model no longer sees a web-search tool aimed at a closed provider")
return nil
}
// fileSnapshot 是可变源在**一次保存之前**的样子(回滚的凭据)。
//
// 存原始字节而不是解析后的结构:回滚的语义是「一字不差地回到那一份」。经结构往返
// (Load + Save)会丢掉人工写在文件里的、本包不认识的键 —— 那正是「损坏配置可人工救回」
// 场景里最不该丢的东西。
type fileSnapshot struct {
// exists 报告保存前文件在不在(不存在时回滚 = 把它删掉)。
exists bool
// raw 是保存前的原始内容(exists 为 false 时无意义)。
raw []byte
}
// snapshot 读一份保存前的快照。文件不存在 = 合法的「还没配过」状态,不是错误。
func (r *webSearchReloader) snapshot() (fileSnapshot, error) {
raw, err := os.ReadFile(r.path)
if err != nil {
if os.IsNotExist(err) {
return fileSnapshot{}, nil
}
return fileSnapshot{}, fmt.Errorf("web-search: snapshot %s: %w", r.path, err)
}
return fileSnapshot{exists: true, raw: raw}, nil
}
// rollback 把文件恢复成快照那一份(temp+rename,与 runtimeconfig 的落盘口径同源:
// 并发读者永远看不到半个文件)。
func (r *webSearchReloader) rollback(snap fileSnapshot) error {
if !snap.exists {
if err := os.Remove(r.path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("web-search: rollback remove %s: %w", r.path, err)
}
return nil
}
tmp := r.path + ".rollback.tmp"
if err := os.WriteFile(tmp, snap.raw, webSearchRollbackFileMode); err != nil {
return fmt.Errorf("web-search: rollback write %s: %w", tmp, err)
}
if err := os.Rename(tmp, r.path); err != nil {
return fmt.Errorf("web-search: rollback rename %s: %w", r.path, err)
}
return nil
}
// unstartableMessage 把「重建失败」的病因翻成**能照着改**的一句话:主 provider 未配齐
// 与名字未登记是两种不同的修法,笼统说一句「配置不可用」等于让用户自己猜。
func unstartableMessage(err error) string {
switch {
case errors.Is(err, search.ErrNotConfigured):
return "这次改动会让 web-search 没有可用的后端(主提供商未配齐:缺 API Key 或接口地址)," +
"后端下次启动会直接失败 ⇒ 已回滚,改动**没有**生效。请先给主提供商补齐凭据,或改选一家已配齐的。"
case errors.Is(err, search.ErrUnknownProvider):
return "这次改动指向了一个未登记的搜索提供商(名字写错了 / 本期没做这一家)," +
"后端下次启动会直接失败 ⇒ 已回滚,改动**没有**生效。"
default:
return "这次改动会让 web-search 起不来(后端下次启动会直接失败)⇒ 已回滚,改动**没有**生效;" +
"详见服务端日志。"
}
}
// withReloadOutcome 把热重载的结局挂到动作响应上(响应体是对象时追加一个 `hotReload`
// 字段;不是对象时原样返回 —— 宁可少一个字段,也不把结果包成另一种形状)。
func withReloadOutcome(res any, outcome webSearchReloadOutcome) any {
m, ok := res.(map[string]any)
if !ok {
return res
}
m["hotReload"] = outcome
return m
}