- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool 窄方法,保留运行期 Policy);下一轮即生效,不要求重启 - 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable - 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填) - 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷) - 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
258 lines
12 KiB
Go
258 lines
12 KiB
Go
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
|
||
}
|