feat(memory): 记忆整理调优面板读写(三态 + 热生效 + 面板表单)
主人 2026-10-07 两条口径:「.env 只做兜底用,后端自动创建独立的面板配置文件
之后就走面板配置文件,这些键优先从面板配置上取」+「频率必须调,可以调到 1 小时
或者三小时,或者动态分配(白天短、静默期长)」。
此前 $DATA_DIR/config/memory-tidy.json 只有读路径(装配期读一次 + 启动播种):
主人要改频率只能改文件 + 重启后端。本切片补上「读回来 → 改 → 落盘 → 当场生效」。
后端(读写端点,契约待塔登记 v0.92):
- GET /api/v1/admin/memories/tidy/tuning 读盘上 7 键值 + 取值规则 specs +
缺位时 .env 档的进程生效值(与 /admin/config-facts 同源)
- PUT 同路径:三态补丁(键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上一
个字节都不动)+ null 清除哨兵(删键 ⇒ 回到下一档)+ writeAuth + 审计
memory.tidy.tuning;值一个都没变时不落盘,文件解不开时拒绝覆盖(409)
- 落盘走 runtimeconfig.PatchMemoryTidyTuning(原子写 temp+rename,与
SeedMemoryTidy/SaveWebSearch 同族同手法);补丁语义保留下受管的其它键
(t1Judge/t1PerWindow/autoApplyPerRound 原样保留)
- 热生效:保存后按「面板 > .env > 代码默认」重算并叠到运行期对象;整理档会
重排心跳(否则新周期要等旧周期那一觉睡完),审查档当场改 ingestGate/tempTtlSec。
未接线/失败时如实回 hot:false + hotError,绝不把「落盘了」说成「生效了」
前端(Miuix):
- 记忆管理页新增「记忆整理调优」卡:7 键表单(中文标签、单位、留空⇒跟随谁、
env 档当前值、坏值就地报错并拦住保存、保存后回显从磁盘读回的真值)
- 试算卡的 recallMode 按契约 v0.91 如实渲染:embedding / lexical(已降级)/
mixed(部分分区降级)/ ""(无候选可比)——空串不再渲染成空标签;并把
error.code=tidy_recall_unavailable 单列一句(「尝试过但向量源不可用」,与
「没有候选可比」显式区分)
验证:go test ./internal/{services/gateway,runtime/memory,config/runtimeconfig}
全绿;三态/清除/坏文件拒写/同值不重写/热生效接线各有用例,并逐条做过变异自证;
tsc --noEmit + vite build 通过;两个 esbuild 前端测试 39 + 34 + 41 checks 通过。
This commit is contained in:
1 parent
17c41265e8
commit
eddf921229
18 files changed
+2604
-5
No files matched your search
@@ -0,0 +1,150 @@
|
||||
// memorytidy_save.go 是 `$DATA_DIR/config/memory-tidy.json` 的**写路径**(面板调优)。
|
||||
//
|
||||
// 为什么需要它(主人 2026-10-07 的两条口径,逐字):「`.env` 只做兜底用 在除了第1次
|
||||
// 部署之后 后端自动创建 独立的面板配置文件之后就走面板配置文件。这些键优先从面板
|
||||
// 配置上取」+「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短
|
||||
// 一点 晚上到了静默期稍微长一点」。在这条写路径出现之前,那个文件**只有读路径**
|
||||
// (LoadMemoryTidyTuning + 启动播种 SeedMemoryTidy):主人要改频率只能改文件 + 重启。
|
||||
//
|
||||
// 它与读取侧(memorytidy.go)是**同一份文件形状的唯一两个门**:decodeMemoryTidy
|
||||
// 仍是判「这份文件能不能被读走」的唯一那一行代码,写路径不另立一套判定。
|
||||
//
|
||||
// 四条口径(与既有的 SaveChannels / SaveWebSearch 同族同手法):
|
||||
//
|
||||
// 1. **补丁语义**:调用方给的是「要设的键(原样 JSON 值)+ 要删的键」,不是整份
|
||||
// 文档。盘上其它键(`t1Judge` / `autoApplyPerRound` …以及将来新增的键)**原样
|
||||
// 保留** —— 一次面板保存绝不把主人手写进文件的其它旋钮抹掉。
|
||||
// 2. **值不变就不落盘**:合并后的文档与盘上那份**语义相同**(同一个 MarshalIndent
|
||||
// 口径比较,而不是比原始字节)时**一个字节都不写**、连 mtime 都不动 —— 主人的
|
||||
// 手写排版不该因为一次空操作被重排。
|
||||
// 3. **读不走的文件拒绝覆盖**:文件存在但不是 JSON 对象 / 结构解不开 ⇒ 返回
|
||||
// `ErrInvalidMemoryTidy`,调用方应**原样报错并拒绝写**(盘上此刻运行期正整体
|
||||
// 回落 `.env`/代码默认,盖掉它只会掩盖问题)。
|
||||
// 4. **原子写**:temp + rename(文件 0600 / 目录 0755,与 SeedMemoryTidy、
|
||||
// SaveWebSearch 一致):并发读者要么看到旧文件、要么看到新文件,不会读到半个
|
||||
// JSON;且**先写好再换名**,任何一步失败都不会留下半份配置。
|
||||
//
|
||||
// 格式上的代价(如实登记):补丁经 `map[string]json.RawMessage` 落盘 ⇒ 键序变成
|
||||
// 字典序、缩进统一为 2 空格。每个键的值一个不差,但人工排过版的文件在**真正发生
|
||||
// 改动时**会被重排一次(口径 2 保证「没改动就不重排」)。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// memoryTidySaveFileMode 是落盘文件的权限(只给跑服务的账号读)。
|
||||
memoryTidySaveFileMode = 0o600
|
||||
// memoryTidySaveDirMode 是 `$DATA_DIR/config/` 的建目录权限。
|
||||
memoryTidySaveDirMode = 0o755
|
||||
)
|
||||
|
||||
// PatchMemoryTidyTuning 把一组**已经过调用方校验**的键值并进
|
||||
// `memory-tidy.json` 并原子写回,返回落盘后**重新读回**的调优结构。
|
||||
//
|
||||
// set 的键是「要写的键 → 原样 JSON 值」(调用方负责值的类型/取值域校验:那是 API
|
||||
// 契约的一部分,属于网关侧;本函数只管**文件形状与落盘**)。clear 是要从文件里删掉
|
||||
// 的键(= 回到下一档兜底)。两者都为空时**不落盘**。
|
||||
//
|
||||
// 返回 (tuning, wrote, err):
|
||||
// - tuning 是**重新读盘**的结果(真值回显,不是「我以为写成了什么」);
|
||||
// - wrote 报告这次是否真的写了字节(内容语义未变时为 false);
|
||||
// - err 非 nil 时**盘上一个字节都没动**;错误链里带 ErrInvalidMemoryTidy 表示
|
||||
// 「盘上那份读不走」⇒ 调用方应拒绝覆盖并如实报错。
|
||||
func PatchMemoryTidyTuning(path string, set map[string]json.RawMessage, clear []string) (MemoryTidyTuning, bool, error) {
|
||||
doc, err := readMemoryTidyDocForPatch(path)
|
||||
if err != nil {
|
||||
return MemoryTidyTuning{}, false, err
|
||||
}
|
||||
before := canonicalMemoryTidyJSON(doc)
|
||||
merged := make(map[string]json.RawMessage, len(doc)+len(set))
|
||||
for key, value := range doc {
|
||||
merged[key] = value
|
||||
}
|
||||
for key, value := range set {
|
||||
merged[key] = value
|
||||
}
|
||||
for _, key := range clear {
|
||||
delete(merged, key)
|
||||
}
|
||||
raw, err := json.MarshalIndent(merged, "", " ")
|
||||
if err != nil {
|
||||
return MemoryTidyTuning{}, false, fmt.Errorf("runtimeconfig: marshal memory tidy: %w", err)
|
||||
}
|
||||
wrote := false
|
||||
if !bytes.Equal(raw, before) {
|
||||
if err := writeMemoryTidyTuningFile(path, raw); err != nil {
|
||||
return MemoryTidyTuning{}, false, err
|
||||
}
|
||||
wrote = true
|
||||
}
|
||||
tuning, err := LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
// 刚落盘就读不回来:这是本函数自己的错误(写出去的内容必须能被自己的读取
|
||||
// 路径读走),如实报错,绝不回一份「成功」。
|
||||
return MemoryTidyTuning{}, wrote, fmt.Errorf("runtimeconfig: reread %s after patch: %w", path, err)
|
||||
}
|
||||
return tuning, wrote, nil
|
||||
}
|
||||
|
||||
// readMemoryTidyDocForPatch 读盘上的文档(键 → 原样 JSON 值)并确认**运行期读得走**。
|
||||
//
|
||||
// 缺失/全空白 ⇒ 空文档(= 还没配过,与 LoadMemoryTidyTuning 同义,不是错误);
|
||||
// 存在但不是 JSON 对象、或整体结构解不开(类型写错)⇒ ErrInvalidMemoryTidy
|
||||
// (口径 3:拒绝覆盖)。
|
||||
func readMemoryTidyDocForPatch(path string) (map[string]json.RawMessage, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]json.RawMessage{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
|
||||
}
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
return map[string]json.RawMessage{}, nil
|
||||
}
|
||||
// 与读取路径**同一行代码**判「这份文件能不能被读走」:一处说坏了、另一处说没事
|
||||
// 就是自相矛盾(写入方会据此把一份坏配置盖掉)。
|
||||
if _, err := decodeMemoryTidy(raw, path); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, fmt.Errorf("%w: decode %s as object: %w", ErrInvalidMemoryTidy, path, err)
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// canonicalMemoryTidyJSON 返回文档的规范化字节(与落盘同一个 MarshalIndent 口径),
|
||||
// 供「值有没有变」的语义比较用。序列化失败时返回 nil —— 那只可能让比较不相等
|
||||
// (= 走正常落盘路径),不会误判成「没变」。
|
||||
func canonicalMemoryTidyJSON(doc map[string]json.RawMessage) []byte {
|
||||
raw, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// writeMemoryTidyTuningFile 原子写出文件(口径 4):建目录 → 写临时文件 → rename。
|
||||
func writeMemoryTidyTuningFile(path string, raw []byte) error {
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, memoryTidySaveDirMode); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, memoryTidySaveFileMode); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件是入库前审查(design/23 §15.1 D3)的存储层:候选读取、软合并落定、
|
||||
@@ -89,6 +91,24 @@ func (s *Store) ingestReviewer() *PreInsertReviewer {
|
||||
return s.review
|
||||
}
|
||||
|
||||
// ApplyIngestPanelTuning 把面板值**当场**叠到已接入的入库前审查上(切片 MEMPANEL / M4)。
|
||||
//
|
||||
// 网关握着的是记忆存储(`deps.Memories`),而不是审查服务本身,因此这条转调就是面板
|
||||
// 写端点触达审查侧的**唯一**通道(不为此新开依赖面:没有新的 Deps 字段、没有新的
|
||||
// Server 字段)。
|
||||
//
|
||||
// 未接入审查(`MEMORY_REVIEW_ENABLED=false`)时是**空操作 + nil**:那时两个键本来
|
||||
// 就不生效,运行期没有任何旧值要改,返回错误只会让一次完全正常的保存报「热生效失败」。
|
||||
// 指针的读写走既有的 `reviewMu`(与 AttachIngestReviewer 同一把锁),不会与并发接入
|
||||
// 交手。
|
||||
func (s *Store) ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
|
||||
reviewer := s.ingestReviewer()
|
||||
if reviewer == nil {
|
||||
return nil
|
||||
}
|
||||
return reviewer.ApplyPanelTuning(panel)
|
||||
}
|
||||
|
||||
// enqueueReview 把刚落库的条目投入审查队列。非阻塞:队列满时审查方自己记
|
||||
// WARN(它才是唯一知道队列状态的一侧),写入方绝不因此阻塞或失败。
|
||||
func (s *Store) enqueueReview(item MemoryItem) {
|
||||
|
||||
@@ -68,3 +68,33 @@ func panelPositiveTTLSec(v *int, key string, log *slog.Logger) int {
|
||||
}
|
||||
return *v
|
||||
}
|
||||
|
||||
// ApplyPanelTuning 把面板值**当场**叠到已经在跑的审查服务上(切片 MEMPANEL / M4)。
|
||||
//
|
||||
// 它只碰面板管得到、且**在 `.env` 里结构性没有出处**的两个键(`ingestGate` /
|
||||
// `tempTtlSec`)—— 因此重算起点就是这两个字段的**零值**(中档为空),不需要一份
|
||||
// 「`.env` 快照」,也不会因为重算而误伤别的键:`Enabled` / `QueueSize` / `ScanLimit` /
|
||||
// `Timeout` / `Threshold` / T1 四件套一律**原样不动**(它们来自部署配置,不是面板旋钮;
|
||||
// 面板端点也拒收它们)。
|
||||
//
|
||||
// 口径与装配期逐字一致(`wireMemoryReview` → `NewPreInsertReviewer`):面板写了就用,
|
||||
// 缺位/坏值由 `ApplyReviewTuning`(WARN + 不覆盖)与 `normalizeReviewConfig`(代码
|
||||
// 默认)接住 —— 这两处仍是**唯一**实现点,本方法不另写一套回落规则。
|
||||
//
|
||||
// 🔴 并发边界(如实登记):写 `r.cfg` 的两处在 `r.mu` 下,但 `preinsert.go` /
|
||||
// `preinsert_gate.go` 读 `r.cfg` 的几处**没有**取 `r.mu`(本切片 scope 不含那两个
|
||||
// 文件,故未一并收口)。这两个字段分别是一个 bool 与一个 int64,在 64 位平台上单字长
|
||||
// 写入不会撕裂,但严格意义上这仍是「写方持锁、读方不持锁」的窄窗口。把 cfg 读改成
|
||||
// 访问器(或换成原子快照)是后续应做的一次收口,已在回报里登记。
|
||||
func (r *PreInsertReviewer) ApplyPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
|
||||
next := normalizeReviewConfig(ApplyReviewTuning(ReviewConfig{}, panel, r.log))
|
||||
r.mu.Lock()
|
||||
r.cfg.DisableIngestGate = panel.IngestGateOff()
|
||||
r.cfg.TempTTL = next.TempTTL
|
||||
gateOn := !r.cfg.DisableIngestGate
|
||||
ttl := r.cfg.TempTTL
|
||||
r.mu.Unlock()
|
||||
r.log.Info("memory review panel tuning applied",
|
||||
"ingest_gate", gateOn, "temp_ttl", ttl.String(), "source", "admin panel")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件是**整理自动档的运行期调优面**(切片 MEMPANEL / M4):把
|
||||
// `$DATA_DIR/config/memory-tidy.json` 的面板旋钮**当场**叠到已经在跑的 TidyService 上,
|
||||
// 使主人点一次「保存」就生效,而不必重启后端。
|
||||
//
|
||||
// 为什么需要它(主人 2026-10-07 两条口径,逐字):
|
||||
//
|
||||
// 「`.env` 只做兜底用 在除了第1次部署之后 后端自动创建 独立的面板配置文件之后就走
|
||||
// 面板配置文件。这些键优先从面板配置上取」
|
||||
// 「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短一点 晚上到
|
||||
// 了静默期稍微长一点」
|
||||
//
|
||||
// 在这之前,面板文件只在**装配期**被读一次(`wireMemoryTidy` → `NewTidyService`),
|
||||
// 四个调度旋钮与 autoApply 被**拷进**服务的 `cfg` 字段后就不再变 —— 面板改完文件,
|
||||
// 跑着的进程一个字节都不会变。
|
||||
//
|
||||
// 三条不可走样的口径:
|
||||
//
|
||||
// 1. **重算而不是叠加**:`base` 是 `.env` 层(由组合根用 `TidyConfigFromConfig`
|
||||
// 从生效配置投影而来),`panel` 是刚落盘的那一份 ⇒ 结果是「面板 > `.env` >
|
||||
// 代码默认」。**不从当前 cfg 上叠**:主人把某个键清掉(`null`)时必须回到
|
||||
// `.env` 档,而不是留在上一次的面板值上。
|
||||
// 2. **心跳要重排**:心跳正睡在「用**旧**周期算出来」的那一觉里(`nextDelay` 只在
|
||||
// 每一拍之前现算),最长可到旧周期。不重排的话,主人把周期从 24 小时改成 3 小时
|
||||
// 时,新周期要等那一觉睡完才生效 —— 那等于「改了跟没改一样」。因此这里 Stop +
|
||||
// Start 把当前那次等待作废,并按新配置重新排一次(`catchUp` 会立刻复核「是不是
|
||||
// 已经到点」,于是缩短周期往往**当场**补跑一轮)。
|
||||
// 3. **失败不吞值**:重排失败(正在跑一轮、排空超时)时**值照样落到运行期**(下一次
|
||||
// 调度判定就用它),并把失败如实回给调用方 —— 绝不假装热生效,也绝不让保存白写。
|
||||
//
|
||||
// 并发口径(为什么这样写是安全的):`s.runMu` 串行化**所有**轮次(心跳轮 `runRound`
|
||||
// 与手动轮 / 试算 `DryRun` 都持它),而两个字段的读者只有这些轮次;因此「持 runMu →
|
||||
// Stop 心跳 → 写 cfg → Start」这一串之后,没有任何 goroutine 会在写入的同时读这两个
|
||||
// 字段。心跳 goroutine 的调度读(`nextDelay` / `dueNow` 读 `s.cfg`)在 Stop 返回后
|
||||
// 已经全部结束,因此也不与写入交手。
|
||||
|
||||
// memoryTidyRescheduleTimeout 是重排心跳时「等正在跑的那一轮跑完」的上限。
|
||||
//
|
||||
// 它存在的理由:`Stop` 要等 wg 排空(可能正有一轮在扫描几百条记忆)。给上限是为了
|
||||
// 不让一次面板保存把 HTTP 请求挂到天荒地老;超时**如实报错**(值照旧落到运行期),
|
||||
// 绝不静默假装重排成功。
|
||||
const memoryTidyRescheduleTimeout = 5 * time.Second
|
||||
|
||||
// ErrTidyRescheduleTimeout 在重排心跳时没能等正在跑的那一轮排空时返回(值已生效于
|
||||
// 下一次调度判定,只是当前那次等待没有被作废)。
|
||||
var ErrTidyRescheduleTimeout = errors.New("memory/tidy: heartbeat reschedule timed out")
|
||||
|
||||
// ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。
|
||||
//
|
||||
// 返回 nil 表示**值已生效、心跳已按新周期重排**;返回错误时**值同样已生效**,只是
|
||||
// 心跳这次没排成(错误文案里说清是哪一种),调用方(网关)据此如实回 `hot:false`。
|
||||
func (s *TidyService) ApplyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning) error {
|
||||
next := tidyPanelTuning(base, panel, s.log)
|
||||
|
||||
// 只把 runMu 拿住 —— 它同时排除「正在跑的一轮」与「刚要开跑的一轮」。注意
|
||||
// 顺序:**先拿 runMu 再 Stop**。反过来的话,心跳若正卡在 runMu 上等开跑,Stop
|
||||
// 会等一个永远等不到退出的 goroutine(它被我抱着锁挡在门外)。
|
||||
s.runMu.Lock()
|
||||
defer s.runMu.Unlock()
|
||||
|
||||
s.mu.Lock()
|
||||
unchanged := s.cfg == next
|
||||
s.mu.Unlock()
|
||||
|
||||
if unchanged {
|
||||
// 面板这次只改了别的键(如 ingestGate):处理配置根本没变 ⇒ 不打扰心跳。
|
||||
s.applyTidyConfigLocked(next)
|
||||
return nil
|
||||
}
|
||||
|
||||
reschedErr := s.rescheduleTidyHeartbeat()
|
||||
s.applyTidyConfigLocked(next)
|
||||
return reschedErr
|
||||
}
|
||||
|
||||
// tidyPanelTuning 是「面板 > `.env` > 代码默认」的**重算点**(口径 1)。
|
||||
//
|
||||
// 它与组合根 `wireMemoryTidy` 里的那一段是**同一条链**(base → autoApply 两个键 →
|
||||
// ApplyTidyScheduleTuning → normalizeTidyConfig),只是可以随时再跑一次。刻意做成
|
||||
// 一个函数而不是把两处各写一遍逻辑,是为了让「优先级链」只有一份可读的写法:
|
||||
// 将来把组合根那一段改成调用它,两处就彻底合一(组合根那个文件不在本切片 scope 内,
|
||||
// 因此这里先留一个等价的实现,并已在回报里登记)。
|
||||
func tidyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
|
||||
cfg := base
|
||||
cfg.DisableAutoApply = panel.AutoApplyOff()
|
||||
if panel.AutoApplyPerRound > 0 {
|
||||
cfg.AutoApplyPerRound = panel.AutoApplyPerRound
|
||||
}
|
||||
cfg = ApplyTidyScheduleTuning(cfg, panel, log)
|
||||
return normalizeTidyConfig(cfg)
|
||||
}
|
||||
|
||||
// applyTidyConfigLocked 把新配置落到**两个**持有者上(调用方必须已持 runMu,并已让
|
||||
// 心跳停下):
|
||||
// - `s.cfg` 是调度判据(`intervalAt` / `inQuietHours`)的来源;
|
||||
// - `s.tidier.cfg` 是每一轮实际用的阈值/上限/自动档开关的来源。
|
||||
//
|
||||
// 两处都要写:只写前者 ⇒ 轮次仍用旧阈值;只写后者 ⇒ 调度仍按旧周期。
|
||||
func (s *TidyService) applyTidyConfigLocked(cfg TidyConfig) {
|
||||
s.mu.Lock()
|
||||
s.cfg = cfg
|
||||
s.mu.Unlock()
|
||||
s.tidier.cfg = cfg
|
||||
}
|
||||
|
||||
// rescheduleTidyHeartbeat 把当前那次等待作废,并按**新配置**重新排一次心跳。
|
||||
//
|
||||
// 未在跑(未 Start / 已 Stop)时是空操作。排空超时时**仍把心跳挂回去**:`Stop` 已经
|
||||
// 把 running 置为 false,不重启就等于让定时整理**悄悄停摆**(本项目明令禁止的静默
|
||||
// 失效形态);旧的那只 goroutine 会在当前这一轮跑完后自行退出。
|
||||
//
|
||||
// 重排用的父 context 是 Background:本对象没有保存父 ctx 的字段(那在
|
||||
// tidy_service.go 里,不在本切片 scope 内),而关闭路径本来就由组合根显式 `Stop`
|
||||
// 收尾(见 wire_services.go),因此不依赖父 ctx 取消。
|
||||
func (s *TidyService) rescheduleTidyHeartbeat() error {
|
||||
s.mu.Lock()
|
||||
running := s.running
|
||||
s.mu.Unlock()
|
||||
if !running {
|
||||
return nil
|
||||
}
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), memoryTidyRescheduleTimeout)
|
||||
defer cancel()
|
||||
if err := s.Stop(stopCtx); err != nil {
|
||||
s.log.Warn("memory tidy tuning applied, but heartbeat reschedule timed out",
|
||||
"timeout", memoryTidyRescheduleTimeout.String(), "error", err)
|
||||
s.Start(context.Background())
|
||||
return fmt.Errorf("%w: %v", ErrTidyRescheduleTimeout, err)
|
||||
}
|
||||
s.Start(context.Background())
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件锁定**运行期调优**(切片 MEMPANEL / M4:面板保存 ⇒ 当场生效,
|
||||
// 不要求重启后端)的两条判据:
|
||||
// ① 整理档:面板值按「面板 > `.env` > 代码默认」**重算**(不是叠在现值上)——
|
||||
// 把键清掉(nil)必须回到 `base` 那一档,而不是留着上一次的面板值;心跳
|
||||
// 在跑时保存会被**重排**(新周期立刻生效),没跑时是空操作。
|
||||
// ② 入库审查档:`ingestGate` / `tempTtlSec` 两个键同样重算(中档结构性为空 ⇒
|
||||
// 清掉即回代码默认),且经 `Store.ApplyIngestPanelTuning` 转调可达。
|
||||
|
||||
// tuningInt 返回面板调优用的 *int。
|
||||
func tuningInt(v int) *int { return &v }
|
||||
|
||||
// tuningBool 返回面板调优用的 *bool。
|
||||
func tuningBool(v bool) *bool { return &v }
|
||||
|
||||
// TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase 面板 > .env > 代码默认,
|
||||
// 且**重算而不是叠加**:清掉一个键就回到 base 那一档。
|
||||
func TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase(t *testing.T) {
|
||||
t.Parallel()
|
||||
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, time.Now, nil)
|
||||
|
||||
// 面板给了三个键 ⇒ 三个都按面板走,没给的键保持 base(.env)档。
|
||||
panel := runtimeconfig.MemoryTidyTuning{
|
||||
IntervalSec: tuningInt(10800),
|
||||
QuietIntervalSec: tuningInt(36000),
|
||||
AutoApply: tuningBool(false),
|
||||
}
|
||||
if err := svc.ApplyPanelTuning(base, panel); err != nil {
|
||||
t.Fatalf("ApplyPanelTuning: %v", err)
|
||||
}
|
||||
if svc.cfg.IntervalSec != 10800 || svc.cfg.Interval != 3*time.Hour {
|
||||
t.Fatalf("interval = %d / %s, want 10800 / 3h", svc.cfg.IntervalSec, svc.cfg.Interval)
|
||||
}
|
||||
if svc.cfg.QuietIntervalSec != 36000 || svc.cfg.QuietInterval != 10*time.Hour {
|
||||
t.Fatalf("quiet interval = %d / %s, want 36000 / 10h",
|
||||
svc.cfg.QuietIntervalSec, svc.cfg.QuietInterval)
|
||||
}
|
||||
if !svc.cfg.DisableAutoApply {
|
||||
t.Fatal("autoApply:false 应当落到 DisableAutoApply")
|
||||
}
|
||||
if svc.cfg.QuietStartHour != 23 || svc.cfg.QuietEndHour != 7 {
|
||||
t.Fatalf("没写 quietStartHour/quietEndHour 时应当保持 base:%+v", svc.cfg)
|
||||
}
|
||||
// 两个持有者都要写:调度读 s.cfg,每一轮读 tidier.cfg。
|
||||
if svc.tidier.cfg.IntervalSec != 10800 || !svc.tidier.cfg.DisableAutoApply {
|
||||
t.Fatalf("tidier 配置没跟上:%+v", svc.tidier.cfg)
|
||||
}
|
||||
|
||||
// 🔴 清掉 intervalSec(模拟面板 null 清除)⇒ 必须回到 base 的 86400,
|
||||
// 而不是留在上一次的 10800。
|
||||
cleared := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(1), QuietEndHour: tuningInt(5)}
|
||||
if err := svc.ApplyPanelTuning(base, cleared); err != nil {
|
||||
t.Fatalf("ApplyPanelTuning(cleared): %v", err)
|
||||
}
|
||||
if svc.cfg.IntervalSec != 86400 {
|
||||
t.Fatalf("清掉面板键后应回到 .env 档 86400,得到 %d(叠加而非重算)", svc.cfg.IntervalSec)
|
||||
}
|
||||
if svc.cfg.QuietIntervalSec != 86400 {
|
||||
t.Fatalf("清掉 quietIntervalSec 后应回到 intervalSec 档,得到 %d", svc.cfg.QuietIntervalSec)
|
||||
}
|
||||
if svc.cfg.DisableAutoApply {
|
||||
t.Fatal("清掉 autoApply 后应回到代码默认(开)")
|
||||
}
|
||||
if svc.cfg.QuietStartHour != 1 || svc.cfg.QuietEndHour != 5 {
|
||||
t.Fatalf("quietStartHour/quietEndHour 应当采用面板值:%+v", svc.cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat 心跳在跑时保存 ⇒ 心跳
|
||||
// 被重排(仍活着,且下一次等待按**新**周期算)。没跑时是空操作。
|
||||
func TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat(t *testing.T) {
|
||||
t.Parallel()
|
||||
at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local)
|
||||
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil)
|
||||
|
||||
// 未启动:保存只改配置,不碰心跳状态(用一个不含 intervalSec 的补丁,免得
|
||||
// 影响下面「旧周期」的断言)。
|
||||
idlePanel := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(22), QuietEndHour: tuningInt(6)}
|
||||
if err := svc.ApplyPanelTuning(base, idlePanel); err != nil {
|
||||
t.Fatalf("ApplyPanelTuning(idle): %v", err)
|
||||
}
|
||||
svc.mu.Lock()
|
||||
running := svc.running
|
||||
svc.mu.Unlock()
|
||||
if running {
|
||||
t.Fatal("未 Start 时不该因为保存而启动心跳")
|
||||
}
|
||||
|
||||
// 启动后:造出「上次跑过」的历史,于是下一拍 = 上次 + 当时时段的周期。
|
||||
// 15:00、上次 14:30、周期 24h ⇒ 下一拍还要等 23.5 小时(旧周期)。
|
||||
svc.Start(nil)
|
||||
t.Cleanup(func() { _ = svc.Stop(nil) })
|
||||
svc.mu.Lock()
|
||||
svc.lastRun = at.Add(-30 * time.Minute)
|
||||
svc.mu.Unlock()
|
||||
if got := svc.nextDelay(); got != 23*time.Hour+30*time.Minute {
|
||||
t.Fatalf("启动后 nextDelay = %s, want 23h30m(旧周期)", got)
|
||||
}
|
||||
// 把周期改短并**重排**:新的下一拍应当按 1 小时的周期算(= 上次 + 1h ⇒ 还有
|
||||
// 30 分钟),而不是继续睡原来那 23.5 小时。
|
||||
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil {
|
||||
t.Fatalf("ApplyPanelTuning(running): %v", err)
|
||||
}
|
||||
svc.mu.Lock()
|
||||
running = svc.running
|
||||
svc.mu.Unlock()
|
||||
if !running {
|
||||
t.Fatal("重排后心跳必须仍在跑(否则定时整理会悄悄停摆)")
|
||||
}
|
||||
if got := svc.nextDelay(); got != 30*time.Minute {
|
||||
t.Fatalf("重排后的 nextDelay = %s, want 30m(仍按旧周期说明没重排)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat 只改别的键(配置没变)
|
||||
// 时不打扰心跳:那一次保存不该白白停一次心跳。
|
||||
func TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat(t *testing.T) {
|
||||
t.Parallel()
|
||||
at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local)
|
||||
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil)
|
||||
svc.Start(nil)
|
||||
t.Cleanup(func() { _ = svc.Stop(nil) })
|
||||
same := normalizeTidyConfig(base)
|
||||
if svc.cfg != same {
|
||||
t.Fatalf("前置条件不成立:%+v vs %+v", svc.cfg, same)
|
||||
}
|
||||
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{}); err != nil {
|
||||
t.Fatalf("ApplyPanelTuning(noop): %v", err)
|
||||
}
|
||||
if got := svc.nextDelay(); got != 8*time.Hour {
|
||||
t.Fatalf("配置没变时不该重排心跳(无历史时仍锚到低峰起点 8h):nextDelay = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys 审查侧的两个键同样
|
||||
// **重算**:清掉即回代码默认(中档结构性为空)。
|
||||
func TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||||
}
|
||||
// 面板关闸 + 1 小时 TTL。
|
||||
panel := runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false), TempTtlSec: tuningInt(3600)}
|
||||
if err := reviewer.ApplyPanelTuning(panel); err != nil {
|
||||
t.Fatalf("ApplyPanelTuning: %v", err)
|
||||
}
|
||||
if !reviewer.cfg.DisableIngestGate {
|
||||
t.Fatal("ingestGate:false 应当当场关掉同步入库闸")
|
||||
}
|
||||
if reviewer.cfg.TempTTL != time.Hour {
|
||||
t.Fatalf("tempTtlSec 未生效:%s", reviewer.cfg.TempTTL)
|
||||
}
|
||||
// 闸的判定面也真的变了(gateEnabled 直接读这份 cfg)。
|
||||
if reviewer.gateEnabled() {
|
||||
t.Fatal("关闸后 gateEnabled 仍为 true")
|
||||
}
|
||||
// 清掉两个键 ⇒ 回到代码默认(闸开、TTL 7 天)。
|
||||
if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{}); err != nil {
|
||||
t.Fatalf("ApplyPanelTuning(cleared): %v", err)
|
||||
}
|
||||
if reviewer.cfg.DisableIngestGate || !reviewer.gateEnabled() {
|
||||
t.Fatal("清掉 ingestGate 后应当回到默认(开)")
|
||||
}
|
||||
if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want {
|
||||
t.Fatalf("清掉 tempTtlSec 后 TTL = %s, want %s(代码默认)", reviewer.cfg.TempTTL, want)
|
||||
}
|
||||
// 坏值(0 秒)不得落进 cfg:回落代码默认,而不是「不过期」也不是「落库即过期」。
|
||||
if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{TempTtlSec: tuningInt(0)}); err != nil {
|
||||
t.Fatalf("ApplyPanelTuning(bad ttl): %v", err)
|
||||
}
|
||||
if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want {
|
||||
t.Fatalf("坏 TTL 应当回落默认 %s,得到 %s", want, reviewer.cfg.TempTTL)
|
||||
}
|
||||
// 审查本身的其它旋钮**一个都不动**(它们不是面板键)。
|
||||
if !reviewer.cfg.Enabled || reviewer.cfg.QueueSize == 0 || reviewer.cfg.ScanLimit == 0 {
|
||||
t.Fatalf("面板调优误伤了别的旋钮:%+v", reviewer.cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer 网关只能通过记忆存储
|
||||
// 触达审查侧(deps.Memories),因此这条转调就是那条通道:接了审查 ⇒ 转调生效;
|
||||
// 没接(审查关闭)⇒ 空操作 + nil(那时两个键本来就不生效)。
|
||||
func TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil {
|
||||
t.Fatalf("未接审查时应当空操作 + nil,得到 %v", err)
|
||||
}
|
||||
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
|
||||
if err != nil {
|
||||
t.Fatalf("NewPreInsertReviewer: %v", err)
|
||||
}
|
||||
store.AttachIngestReviewer(reviewer)
|
||||
if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil {
|
||||
t.Fatalf("Store.ApplyIngestPanelTuning: %v", err)
|
||||
}
|
||||
if !reviewer.cfg.DisableIngestGate {
|
||||
t.Fatal("经 Store 的转调没有落到审查服务上")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件承载记忆整理「面板调优」的**管理读写端点**(契约待塔登记,见回报):
|
||||
//
|
||||
// GET /api/v1/admin/memories/tidy/tuning 读:盘上的 7 键值 + 取值规则
|
||||
// PUT /api/v1/admin/memories/tidy/tuning 写:三态补丁 + 原子落盘 + 热生效
|
||||
//
|
||||
// 它回答的是主人的两句原话(逐字,见 M4 mission Context):
|
||||
//
|
||||
// 「`.env` 只做兜底用 在除了第1次部署之后 后端自动创建 独立的面板配置文件之后就走
|
||||
// 面板配置文件。这些键优先从面板配置上取」
|
||||
// 「频率必须调 可以调 1 小时或者三小时 或者动态分配,比如白天是间隔短一点 晚上到了
|
||||
// 静默期稍微长一点」
|
||||
//
|
||||
// 此前 `$DATA_DIR/config/memory-tidy.json` **只有读路径**(`cmd/cyrene/wire_memory_tidy.go`
|
||||
// 在装配期读一次 + 启动播种):主人要改频率只能 SSH 改文件 + 重启后端。本切片把
|
||||
// 「读回来 → 改 → 落盘」这条面板路径补上,**零新 env 键**(`.env` 已冻结,
|
||||
// design/13 §2.1)、**不新开文件家族**。
|
||||
//
|
||||
// 四条不可走样的口径:
|
||||
//
|
||||
// 1. **三态**(硬要求,与契约里 `plugin.saveProvider` 同族语义):键**缺位** ⇒ 不动;
|
||||
// **合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节都不动**。
|
||||
// `null` 是这一族的清除哨兵(= 删掉该键、回到下一档兜底)。三态的实现与
|
||||
// 「非法值不落盘」的证据在 `admin_memory_tuning_file.go` 的
|
||||
// `parseMemoryTidyTuningPatch`(解析阶段就拒,落盘只在解析成功之后发生)。
|
||||
// 2. **写端点带审计**(动作 `memory.tidy.tuning`),且**不改** `.env`、不重启后端。
|
||||
// 3. **热生效**:保存后把新值当场叠到运行期(可选能力,见 MemoryTidyTuningApplier)。
|
||||
// 能力不存在或失败时,响应 `hot:false` + `hotError`,面板**如实**说「已落盘、
|
||||
// 要重启后端」——绝不把「落盘了」说成「生效了」。
|
||||
// 4. **诚实回话**:写成功后的响应是**重新读盘**的结果(真值回显),不是「我以为
|
||||
// 写成了什么」;盘上文件解不开时读/写**都**拒绝(`409 tuning_corrupt`)并说明
|
||||
// 运行期此刻正回落 `.env`/代码默认。
|
||||
//
|
||||
// 权限:GET 与 PUT 都走 **writeAuth(owner|admin)**(与本族其它记忆端点
|
||||
// `/admin/memories`、`POST /admin/memories/{id}/revert` 同档)——它是私有记忆面的
|
||||
// 运行期旋钮,不是给 viewer 的监控投影。
|
||||
|
||||
// memoryTuningLogSource 是本子功能的日志来源(模块/子功能可追溯,AGENTS §10)。
|
||||
const memoryTuningLogSource = "gateway/memory-tuning"
|
||||
|
||||
// memoryTuningAuditAction 是写端点落审计的动作名(与 memory.tidy.apply /
|
||||
// memory.tidy.reject 同一族命名)。
|
||||
const memoryTuningAuditAction = "memory.tidy.tuning"
|
||||
|
||||
// memoryTuningMaxBodyBytes 是写端点请求体的墙钟上限。这个文件是给人改的小配置
|
||||
// (11 个标量键),正常请求体不到 200 字节;给上限只为让畸形/超大请求不占内存,
|
||||
// 不是业务上限。
|
||||
const memoryTuningMaxBodyBytes = 16 << 10
|
||||
|
||||
// MemoryTidyTuningApplier 是「把面板值当场叠到**整理自动档**」的可选能力。
|
||||
//
|
||||
// 它**不是**必需依赖:组合根注入的整理服务(`deps.MemoryTidy` = `*memory.TidyService`)
|
||||
// 实现了它 ⇒ 保存后立刻生效;没实现 ⇒ 值照旧落盘、但要等后端重启,响应里
|
||||
// `hot:false` 如实说出来(口径 3)。
|
||||
//
|
||||
// base 是**`.env` 层**(由 `memory.TidyConfigFromConfig` 从进程生效配置投影而来,
|
||||
// 与 wireMemoryTidy 用的是同一个唯一实现点);panel 是刚落盘的那一份。实现必须按
|
||||
// 「面板 > `.env` > 代码默认」逐键重算,且**缺位键保持 base 的值** —— 也就是
|
||||
// wireMemoryTidy 当初那一遍的同一口径,只是可以随时再跑一次。
|
||||
type MemoryTidyTuningApplier interface {
|
||||
ApplyPanelTuning(base memory.TidyConfig, panel runtimeconfig.MemoryTidyTuning) error
|
||||
}
|
||||
|
||||
// MemoryIngestTuningApplier 同理,服务于**同步入库闸 / 入库前审查**一侧
|
||||
// (`ingestGate` / `tempTtlSec`)——它的持有者是记忆存储,因此由 `deps.Memories`
|
||||
// 那个出口解析(网关不为此新开依赖面)。
|
||||
type MemoryIngestTuningApplier interface {
|
||||
ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error
|
||||
}
|
||||
|
||||
// AdminMemoryTidyTuningView 是 GET / PUT `/admin/memories/tidy/tuning` 的 data。
|
||||
type AdminMemoryTidyTuningView struct {
|
||||
// Path 是面板配置文件的落点(**相对 `$DATA_DIR`**,如 `config/memory-tidy.json`):
|
||||
// 面板据此如实告诉主人改的是哪个文件,而不必把部署绝对路径下发。
|
||||
Path string `json:"path"`
|
||||
// Present 报告那份文件是否已存在且有内容。false = 还没播种(后端**下次启动**
|
||||
// 会自动按当时生效值创建它,见 runtimeconfig.SeedMemoryTidy)。
|
||||
Present bool `json:"present"`
|
||||
// Hot 报告这次结果是否**当场生效**(true)。false = 已落盘,但要等后端重启 ——
|
||||
// 面板必须照实说,不得显示成「已生效」。GET 里恒为 false:只读不改变任何东西,
|
||||
// 「生效」这个问题在只读路径上没有意义。
|
||||
Hot bool `json:"hot"`
|
||||
// HotError 在「已保存、但没能在运行期立刻生效」时给出原因(`hot:false` 时才有)。
|
||||
HotError string `json:"hotError,omitempty"`
|
||||
// Specs 是这 7 个键的**取值规则**(类型/单位/上下界/兜底来源),随读下发 ⇒
|
||||
// 面板的表单与就地校验都由它驱动,不会出现「面板允许、后端 400」的两套规则。
|
||||
Specs []memoryTidyTuningSpec `json:"specs"`
|
||||
// Values 是**盘上**的值:键缺位 ⇒ 整个键不出现(= 跟随兜底),不编一个 0 ——
|
||||
// 「写了 0」(坏值,运行期会 WARN 回落)与「没写」在面板上必须看得出区别。
|
||||
Values map[string]any `json:"values"`
|
||||
// EnvValues 是缺位时**下一档(`.env`)**的进程生效值(只读下发):面板据此写
|
||||
// 「留空 ⇒ 跟随 `.env` 的 86400」,而不必自己猜一个数。与
|
||||
// GET /admin/config-facts 同源(都取 `s.cfg`,网关绝不直接 fopen `.env`)。
|
||||
// 某个键**连 `.env` 档也没有值**(未填 / 结构性无此键)时它不出现。
|
||||
EnvValues map[string]int `json:"envValues,omitempty"`
|
||||
}
|
||||
|
||||
// adminMemoryTidyTuningRead 是 GET 的处理函数:只读、不审计(与
|
||||
// /admin/config-facts、tidy 试算同一档:读事实不改状态)。
|
||||
func (s *Server) adminMemoryTidyTuningRead(w http.ResponseWriter, _ *http.Request) {
|
||||
path, ok := s.memoryTidyTuningPath()
|
||||
if !ok {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_unavailable", "数据目录未接线,面板配置文件落点无法确定"))
|
||||
return
|
||||
}
|
||||
view, err := s.buildMemoryTidyTuningView(path)
|
||||
if err != nil {
|
||||
s.writeTuningCorrupt(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// adminMemoryTidyTuningWrite 是 PUT 的处理函数:三态补丁 → 原子落盘 → 热生效 →
|
||||
// 审计。顺序是刻意的:**校验全部发生在落盘之前**(口径 1),落盘之后才谈生效。
|
||||
func (s *Server) adminMemoryTidyTuningWrite(w http.ResponseWriter, r *http.Request) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, memoryTuningMaxBodyBytes))
|
||||
if err != nil {
|
||||
s.auditClaims(r, memoryTuningAuditAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "读取请求体失败"))
|
||||
return
|
||||
}
|
||||
patch, err := parseMemoryTidyTuningPatch(body)
|
||||
if err != nil {
|
||||
// 非法值/未知键:**盘上一个字节都不动**(解析阶段就退出,写盘在下面)。
|
||||
s.auditClaims(r, memoryTuningAuditAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_payload", "%v", err))
|
||||
return
|
||||
}
|
||||
path, ok := s.memoryTidyTuningPath()
|
||||
if !ok {
|
||||
s.auditClaims(r, memoryTuningAuditAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_unavailable", "数据目录未接线,面板配置文件落点无法确定"))
|
||||
return
|
||||
}
|
||||
// 落盘(补丁 + 原子写)交给 runtimeconfig 家族:它同时是「盘上那份能不能被
|
||||
// 运行期读走」的**唯一**判定点(读不走的文件拒绝覆盖,见 ErrInvalidMemoryTidy)。
|
||||
if !patch.empty() {
|
||||
if err := s.commitMemoryTidyTuningPatch(path, patch); err != nil {
|
||||
s.auditClaims(r, memoryTuningAuditAction, "error")
|
||||
s.writeTuningWriteFailure(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
view, err := s.buildMemoryTidyTuningView(path)
|
||||
if err != nil {
|
||||
// 刚落盘就解不开:这是本端点的编程/磁盘错误,如实回 500,绝不回一个
|
||||
// 「已保存」的假成功。
|
||||
s.auditClaims(r, memoryTuningAuditAction, "error")
|
||||
s.writeTuningCorrupt(w, err)
|
||||
return
|
||||
}
|
||||
hot, hotErr := s.applyMemoryTidyTuningHot(path)
|
||||
view.Hot = hot
|
||||
if hotErr != nil {
|
||||
view.HotError = hotErr.Error()
|
||||
}
|
||||
s.auditClaims(r, memoryTuningAuditAction, "ok")
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// commitMemoryTidyTuningPatch 把补丁交给 runtimeconfig 家族原子落盘。值一个都没变
|
||||
// 时不落盘(「保存同样的值」不该在盘上留一次无意义的改写)。
|
||||
func (s *Server) commitMemoryTidyTuningPatch(path string, patch memoryTidyTuningPatch) error {
|
||||
_, wrote, err := runtimeconfig.PatchMemoryTidyTuning(path, patch.set, patch.clear)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log := logger.WithSource(s.log, memoryTuningLogSource)
|
||||
if !wrote {
|
||||
log.Info("memory tidy tuning unchanged (no bytes written)", "path", path, "keys", patch.changed)
|
||||
return nil
|
||||
}
|
||||
log.Info("memory tidy tuning saved", "path", path, "keys", patch.changed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMemoryTidyTuningView 读盘并组装面板视图(口径 4:响应就是**盘上真值**)。
|
||||
func (s *Server) buildMemoryTidyTuningView(path string) (AdminMemoryTidyTuningView, error) {
|
||||
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
return AdminMemoryTidyTuningView{}, err
|
||||
}
|
||||
present, err := memoryTidyTuningPresent(path)
|
||||
if err != nil {
|
||||
return AdminMemoryTidyTuningView{}, err
|
||||
}
|
||||
return AdminMemoryTidyTuningView{
|
||||
Path: runtimeconfig.MemoryTidyConfigRelPath,
|
||||
Present: present,
|
||||
Specs: memoryTidyTuningSpecs(),
|
||||
Values: memoryTidyTuningValues(tuning),
|
||||
EnvValues: s.memoryTidyTuningEnvValues(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// memoryTidyTuningPresent 报告面板配置文件是否已存在且**有内容**(全空白 = 还没配过,
|
||||
// 与 runtimeconfig.LoadMemoryTidyTuning 的口径一致)。
|
||||
func memoryTidyTuningPresent(path string) (bool, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("读取 %s:%w", path, err)
|
||||
}
|
||||
return len(strings.TrimSpace(string(raw))) > 0, nil
|
||||
}
|
||||
|
||||
// memoryTidyTuningEnvValues 返回缺位时的**下一档(`.env`)**进程生效值。
|
||||
//
|
||||
// 只取 `s.cfg`(配置层已消化「进程环境 → `.env` → 声明默认」):网关**绝不**
|
||||
// 直接 fopen `.env`,否则面板看到的是文件原文而不是运行实例在用的数。
|
||||
//
|
||||
// 三处刻意省略(省略 = 「这一档也没值,继续落到代码默认」,而不是编一个 0):
|
||||
// - `MEMORY_TIDY_INTERVAL_SEC` ≤ 0:非正数是坏值(运行期会 WARN 并回落),
|
||||
// 报出来会让人以为「留空就得到 0 秒」;
|
||||
// - 安静时段两端**都为 0**:0/0 是「未填」,运行期会回落到设计默认窗口,
|
||||
// 此时报 0/0 等于把「未填」谎报成一个午夜窗口。
|
||||
//
|
||||
// 其余四个键(`quietIntervalSec` / `ingestGate` / `autoApply` / `tempTtlSec`)在
|
||||
// `.env` 里**结构性没有出处**(`.env` 已冻结,design/13 §2.1),因此不存在第二档,
|
||||
// 它们的下一档就是代码默认(specs 里的 fallback 已说明)。
|
||||
func (s *Server) memoryTidyTuningEnvValues() map[string]int {
|
||||
out := map[string]int{}
|
||||
if s.cfg == nil {
|
||||
return out
|
||||
}
|
||||
if v := s.cfg.MemoryTidy.IntervalSec; v > 0 {
|
||||
out["intervalSec"] = v
|
||||
}
|
||||
if start, end := s.cfg.MemoryTidy.QuietStartHour, s.cfg.MemoryTidy.QuietEndHour; start != 0 || end != 0 {
|
||||
out["quietStartHour"] = start
|
||||
out["quietEndHour"] = end
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// memoryTidyTuningPath 解析面板配置文件的绝对落点(`$DATA_DIR/config/memory-tidy.json`)。
|
||||
//
|
||||
// 数据目录的来源与图片上传端点同一口径:优先已解析的 `deps.DataDir`,为空时回退
|
||||
// `cfg.DataDir`;两者都空(未接线)时报 false,调用方如实回 `tuning_unavailable`,
|
||||
// 绝不猜一个目录去写。
|
||||
func (s *Server) memoryTidyTuningPath() (string, bool) {
|
||||
dataDir := s.dataDir
|
||||
if dataDir == "" && s.cfg != nil {
|
||||
dataDir = s.cfg.DataDir
|
||||
}
|
||||
if dataDir == "" {
|
||||
return "", false
|
||||
}
|
||||
return runtimeconfig.DefaultMemoryTidyPath(dataDir), true
|
||||
}
|
||||
|
||||
// applyMemoryTidyTuningHot 把刚落盘的面板值当场叠到运行期(口径 3)。
|
||||
//
|
||||
// 返回 (hot, err):hot=false 表示这次**没能在运行期生效**(能力未接线,或叠的过程
|
||||
// 报错),err 说明原因。两者只用于如实回话,**不影响**「已经落盘」这个事实 ——
|
||||
// 值不会因为热生效失败而丢。
|
||||
//
|
||||
// 能力用类型断言探测(可选能力,与 `plugin` 侧的可选接口同一手法):网关**不为此**
|
||||
// 新增 Deps 字段/Server 字段 —— 整理服务本来就在 `s.memoryTidy` 里,审查器挂在
|
||||
// `deps.Memories` 的实现上。这样「有没有热生效」是一件可观测的事实,而不是一句
|
||||
// 靠不住的断言。
|
||||
func (s *Server) applyMemoryTidyTuningHot(path string) (hot bool, hotErr error) {
|
||||
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("重读面板配置失败:%v", err)
|
||||
}
|
||||
applied := false
|
||||
if applier, ok := s.memoryTidy.(MemoryTidyTuningApplier); ok {
|
||||
base := memory.TidyConfig{}
|
||||
if s.cfg != nil {
|
||||
base = memory.TidyConfigFromConfig(s.cfg.MemoryTidy)
|
||||
}
|
||||
if err := applier.ApplyPanelTuning(base, tuning); err != nil {
|
||||
return false, fmt.Errorf("整理自动档未能热生效:%v", err)
|
||||
}
|
||||
applied = true
|
||||
}
|
||||
if applier, ok := s.memories.(MemoryIngestTuningApplier); ok {
|
||||
if err := applier.ApplyIngestPanelTuning(tuning); err != nil {
|
||||
return false, fmt.Errorf("入库审查未能热生效:%v", err)
|
||||
}
|
||||
applied = true
|
||||
}
|
||||
if !applied {
|
||||
return false, fmt.Errorf("运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// writeTuningCorrupt 把「盘上那份读不走」映射成 409 tuning_corrupt,并把**运行期的
|
||||
// 实际后果**写进文案:此刻它正整体回落 `.env`/代码默认(见 runtimeconfig 的读取口径),
|
||||
// 而面板**拒绝**在这个文件上覆盖 —— 坏文件请人工修复或删除。
|
||||
func (s *Server) writeTuningCorrupt(w http.ResponseWriter, cause error) {
|
||||
logger.WithSource(s.log, memoryTuningLogSource).Warn(
|
||||
"memory tidy tuning file unusable", "path", runtimeconfig.MemoryTidyConfigRelPath, "error", cause)
|
||||
writeError(w, apperr.Wrap(apperr.ErrConflict, "tuning_corrupt",
|
||||
"面板配置文件 %s 解不开(运行期正回落 .env / 代码默认);面板拒绝在一个读不走的文件上覆盖,请人工修复或删除它:%v",
|
||||
runtimeconfig.MemoryTidyConfigRelPath, cause))
|
||||
}
|
||||
|
||||
// writeTuningWriteFailure 把落盘失败如实分类:**盘上那份读不走**(ErrInvalidMemoryTidy)
|
||||
// 是 409(拒绝覆盖,不是故障),其余(IO/权限/重读不一致)是 500。两种都带出处,
|
||||
// 绝不回一个「已保存」的假成功。
|
||||
func (s *Server) writeTuningWriteFailure(w http.ResponseWriter, cause error) {
|
||||
if errors.Is(cause, runtimeconfig.ErrInvalidMemoryTidy) {
|
||||
s.writeTuningCorrupt(w, cause)
|
||||
return
|
||||
}
|
||||
logger.WithSource(s.log, memoryTuningLogSource).Error(
|
||||
"memory tidy tuning write failed", "path", runtimeconfig.MemoryTidyConfigRelPath, "error", cause)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_write_failed",
|
||||
"面板配置文件 %s 落盘失败(盘上内容未改动):%v", runtimeconfig.MemoryTidyConfigRelPath, cause))
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// 本文件是记忆整理「面板调优」的**键规格与三态校验**层(文件读写与原子落盘在
|
||||
// `internal/config/runtimeconfig` 家族里,见 memorytidy_save.go —— 落点是那一族的
|
||||
// 职责,网关只负责 API 契约这一侧的取值规则)。
|
||||
//
|
||||
// 它管的是 `$DATA_DIR/config/memory-tidy.json` 里主人能调的 7 个旋钮(design/13 §2.1:
|
||||
// 调优/门槛/上限/开关类参数走 `$DATA_DIR/config/**` + 代码默认值,不进 `.env`)。
|
||||
//
|
||||
// 三态与清除哨兵(与契约里 `plugin.saveProvider` 同族语义):
|
||||
// - 键**没出现** ⇒ 不动它(`changed` 里也不出现);
|
||||
// - 值为 `null` ⇒ 从面板文件里**删掉**这个键(回到下一档兜底:`.env` → 代码默认);
|
||||
// - 值合法 ⇒ 采用;
|
||||
// - 值非法(类型不对 / 超界 / 不是 JSON 对象 / 有不认识的键)⇒ 一律**在解析阶段**
|
||||
// 被拒(调用方回 400 且盘上一个字节都不动),**绝不静默忽略** —— 静默忽略会让
|
||||
// 主人以为改了而实际没改(把 `quietEndHour` 拼成 `quietEnd` 就正是这种形态)。
|
||||
|
||||
const (
|
||||
// tuningKindInt / tuningKindBool 是本切片管得到的两种键型。
|
||||
tuningKindInt = "int"
|
||||
tuningKindBool = "bool"
|
||||
// tuningUnitSec / tuningUnitHour 是 int 键的单位(面板据此写「秒 / 点」)。
|
||||
tuningUnitSec = "sec"
|
||||
tuningUnitHour = "hour"
|
||||
// tuningSecMin 是三个秒键的下界:**必须为正**(≤0 的周期不是「没配」,而是一个
|
||||
// 会让整理退化成空转的值;运行期口径同样把非正数当坏值回落,见
|
||||
// memory.ApplyTidyScheduleTuning 与 runtimeconfig.MemoryTidyTuning 的注释)。
|
||||
tuningSecMin = 1
|
||||
// tuningHourMin / tuningHourMax 是钟点键的取值域(本地钟点 0..23)。
|
||||
tuningHourMin = 0
|
||||
tuningHourMax = 23
|
||||
|
||||
// tuningCodeDefault 是「缺位时落到代码默认」这一类兜底来源的标识(面板文案用)。
|
||||
// 它**只说来源、不带数值**:数值的唯一真源在 memory 侧(normalizeTidyConfig /
|
||||
// normalizeReviewConfig),网关不另抄一份默认值 —— 抄一份就是第二个真源。
|
||||
tuningCodeDefault = "code-default"
|
||||
)
|
||||
|
||||
// memoryTidyTuningSpec 是一个可写键的**取值规则**(唯一真源)。
|
||||
//
|
||||
// 它既驱动写侧校验,也随 GET 下发给面板(`specs`):面板据此渲染表单与就地报错,
|
||||
// 因此不会出现「面板允许、后端 400」的两套规则(与 /admin/llm 下发
|
||||
// reasoningKinds / personaProviderOptions 同一手法)。
|
||||
type memoryTidyTuningSpec struct {
|
||||
Key string `json:"key"`
|
||||
// Kind ∈ int | bool;面板据此选输入控件类型。
|
||||
Kind string `json:"kind"`
|
||||
// Unit ∈ sec | hour(仅 int 键):面板据此写单位,不自己猜。
|
||||
Unit string `json:"unit,omitempty"`
|
||||
// Min / Max 是闭区间上下界(缺省 = 该端无约束)。
|
||||
Min *int `json:"min,omitempty"`
|
||||
Max *int `json:"max,omitempty"`
|
||||
// Fallback 报告这个键**缺位(或面板 `null` 清除)时**生效值从哪来:
|
||||
// - `env:MEMORY_TIDY_*` ⇒ 回落 `.env` 的同名键(进程生效值见
|
||||
// GET /admin/config-facts 的同名事实行);
|
||||
// - `panel:<key>` ⇒ 回落面板文件里另一个键;
|
||||
// - `code-default` ⇒ 回落代码默认。
|
||||
// 它只给**来源**,不给数值:数值的真源在各自那一档(见 tuningCodeDefault)。
|
||||
Fallback string `json:"fallback"`
|
||||
}
|
||||
|
||||
// memoryTidyTuningSpecs 是面板可写 7 键的**唯一清单**(顺序 = 面板显示顺序)。
|
||||
//
|
||||
// 为什么是这 7 个:主人要能调**整理频率**(白天短、静默期长)与**入库审查的两个
|
||||
// 开关/时长**,而 `.env` 已冻结(design/13 §2.1)⇒ 这些旋钮只能落面板配置文件,
|
||||
// 面板必须能**写**它。
|
||||
//
|
||||
// 刻意**不在**这里的两类键:
|
||||
// - `t1Judge` / `t1PerWindow` / `t1WindowSec` / `autoApplyPerRound`:同文件的其它
|
||||
// 旋钮,本切片的面板卡片不呈现,写端点也不接受它们(未知键一律 400,免得主人
|
||||
// 以为改了、其实没动)。它们仍**原样保留**在文件里(补丁语义)。
|
||||
func memoryTidyTuningSpecs() []memoryTidyTuningSpec {
|
||||
secMin := tuningSecMin
|
||||
hourMin := tuningHourMin
|
||||
hourMax := tuningHourMax
|
||||
return []memoryTidyTuningSpec{
|
||||
{
|
||||
Key: "intervalSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin,
|
||||
Fallback: "env:MEMORY_TIDY_INTERVAL_SEC",
|
||||
},
|
||||
{
|
||||
Key: "quietIntervalSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin,
|
||||
Fallback: "panel:intervalSec",
|
||||
},
|
||||
{
|
||||
Key: "quietStartHour", Kind: tuningKindInt, Unit: tuningUnitHour,
|
||||
Min: &hourMin, Max: &hourMax, Fallback: "env:MEMORY_TIDY_QUIET_START_HOUR",
|
||||
},
|
||||
{
|
||||
Key: "quietEndHour", Kind: tuningKindInt, Unit: tuningUnitHour,
|
||||
Min: &hourMin, Max: &hourMax, Fallback: "env:MEMORY_TIDY_QUIET_END_HOUR",
|
||||
},
|
||||
{Key: "ingestGate", Kind: tuningKindBool, Fallback: tuningCodeDefault},
|
||||
{Key: "autoApply", Kind: tuningKindBool, Fallback: tuningCodeDefault},
|
||||
{Key: "tempTtlSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin, Fallback: tuningCodeDefault},
|
||||
}
|
||||
}
|
||||
|
||||
// memoryTidyTuningManagedKeys 返回可写键的键名(specs 顺序),供错误文案与审计用。
|
||||
func memoryTidyTuningManagedKeys() []string {
|
||||
specs := memoryTidyTuningSpecs()
|
||||
out := make([]string, 0, len(specs))
|
||||
for _, s := range specs {
|
||||
out = append(out, s.Key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// memoryTidyTuningPatch 是一次写请求**已校验**的补丁。
|
||||
//
|
||||
// set / clear 是交给 runtimeconfig.PatchMemoryTidyTuning 的动作(clear = 删掉该键
|
||||
// ⇒ 回到下一档兜底);changed 是本次真正出现的键(specs 顺序,含被清除的),空表示
|
||||
// 一个键都没提交(此时**不写盘**,文件字节保持不变)。
|
||||
type memoryTidyTuningPatch struct {
|
||||
set map[string]json.RawMessage
|
||||
clear []string
|
||||
changed []string
|
||||
}
|
||||
|
||||
// empty 报告这次请求没有提交任何受管的键。
|
||||
func (p memoryTidyTuningPatch) empty() bool { return len(p.changed) == 0 }
|
||||
|
||||
// parseMemoryTidyTuningPatch 把请求体解析成一次已校验的补丁(三态的唯一实现点)。
|
||||
//
|
||||
// 未知键也归入「非法」:本端点**不静默忽略**任何提交上来的键 —— 静默忽略会让主人
|
||||
// 以为改了而实际没改。
|
||||
func parseMemoryTidyTuningPatch(body []byte) (memoryTidyTuningPatch, error) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return memoryTidyTuningPatch{}, fmt.Errorf("请求体必须是一个 JSON 对象:%w", err)
|
||||
}
|
||||
if raw == nil {
|
||||
// JSON 的 `null` 解到 map 上是 nil 且**不报错**:它不是一个「提交了 0 个键」
|
||||
// 的请求,而是一个形状不对的请求,如实拒掉(空补丁请用 `{}`)。
|
||||
return memoryTidyTuningPatch{}, errors.New("请求体必须是一个 JSON 对象(收到 null)")
|
||||
}
|
||||
specs := memoryTidyTuningSpecs()
|
||||
known := make(map[string]memoryTidyTuningSpec, len(specs))
|
||||
for _, s := range specs {
|
||||
known[s.Key] = s
|
||||
}
|
||||
if err := rejectUnknownTuningKeys(raw, known); err != nil {
|
||||
return memoryTidyTuningPatch{}, err
|
||||
}
|
||||
patch := memoryTidyTuningPatch{set: map[string]json.RawMessage{}}
|
||||
for _, s := range specs {
|
||||
value, ok := raw[s.Key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
patch.changed = append(patch.changed, s.Key)
|
||||
if isJSONNull(value) {
|
||||
patch.clear = append(patch.clear, s.Key)
|
||||
continue
|
||||
}
|
||||
if err := validateTuningValue(s, value); err != nil {
|
||||
return memoryTidyTuningPatch{}, err
|
||||
}
|
||||
patch.set[s.Key] = value
|
||||
}
|
||||
return patch, nil
|
||||
}
|
||||
|
||||
// rejectUnknownTuningKeys 拒绝请求体里任何不受管的键(含拼错的键)。
|
||||
func rejectUnknownTuningKeys(raw map[string]json.RawMessage, known map[string]memoryTidyTuningSpec) error {
|
||||
unknown := make([]string, 0)
|
||||
for k := range raw {
|
||||
if _, ok := known[k]; !ok {
|
||||
unknown = append(unknown, k)
|
||||
}
|
||||
}
|
||||
if len(unknown) == 0 {
|
||||
return nil
|
||||
}
|
||||
sort.Strings(unknown)
|
||||
return fmt.Errorf("不认识的键:%s(本端点只写 %s;同文件其它旋钮请人工编辑)",
|
||||
strings.Join(unknown, "、"), strings.Join(memoryTidyTuningManagedKeys(), "、"))
|
||||
}
|
||||
|
||||
// validateTuningValue 校验一个键的值(类型 + 取值域)。错误文案直接回给面板显示,
|
||||
// 因此带上**收到的原始值**,让主人一眼看出该改什么。
|
||||
func validateTuningValue(spec memoryTidyTuningSpec, raw json.RawMessage) error {
|
||||
switch spec.Kind {
|
||||
case tuningKindBool:
|
||||
var b bool
|
||||
if err := json.Unmarshal(raw, &b); err != nil {
|
||||
return fmt.Errorf("%s 必须是 true 或 false(收到 %s)", spec.Key, clipJSON(raw))
|
||||
}
|
||||
return nil
|
||||
case tuningKindInt:
|
||||
var n int
|
||||
if err := json.Unmarshal(raw, &n); err != nil {
|
||||
return fmt.Errorf("%s 必须是整数(收到 %s)", spec.Key, clipJSON(raw))
|
||||
}
|
||||
if spec.Min != nil && n < *spec.Min {
|
||||
return fmt.Errorf("%s 必须 ≥ %d(%s),收到 %d", spec.Key, *spec.Min, tuningUnitLabel(spec), n)
|
||||
}
|
||||
if spec.Max != nil && n > *spec.Max {
|
||||
return fmt.Errorf("%s 必须 ≤ %d(%s),收到 %d", spec.Key, *spec.Max, tuningUnitLabel(spec), n)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
// 规格表自己写错了型别(编程错误,不是客户端错):如实报出来,不猜一种校验。
|
||||
return fmt.Errorf("%s 的键规格未定义(kind=%q)", spec.Key, spec.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// tuningUnitLabel 返回单位的中文说明(错误文案用)。
|
||||
func tuningUnitLabel(spec memoryTidyTuningSpec) string {
|
||||
switch spec.Unit {
|
||||
case tuningUnitSec:
|
||||
return "秒"
|
||||
case tuningUnitHour:
|
||||
return "本地钟点 0-23"
|
||||
default:
|
||||
return "整数"
|
||||
}
|
||||
}
|
||||
|
||||
// clipJSON 把收到的原始 JSON 片段截断成可安全回显的短串(错值可能很长)。
|
||||
func clipJSON(raw json.RawMessage) string {
|
||||
const maxClip = 40
|
||||
s := strings.TrimSpace(string(raw))
|
||||
if len(s) <= maxClip {
|
||||
return s
|
||||
}
|
||||
return s[:maxClip] + "…"
|
||||
}
|
||||
|
||||
// isJSONNull 报告这段原始值是不是 JSON 的 `null`(清除哨兵)。
|
||||
func isJSONNull(raw json.RawMessage) bool {
|
||||
return strings.TrimSpace(string(raw)) == "null"
|
||||
}
|
||||
|
||||
// memoryTidyTuningValues 把盘上的调优结构投影成面板要的 7 键值表:**键缺位 ⇒ 整个
|
||||
// 键不出现**(面板据此渲染「未设置、跟随兜底」),而不是回一个编出来的 0 ——
|
||||
// 「0 秒」与「没配」在面板上必须看得出区别。
|
||||
//
|
||||
// 它刻意**不**回显同文件的其它键(t1Judge 等):那些不在本卡片的编辑范围,混进来
|
||||
// 只会让人以为这张卡能改它们。
|
||||
func memoryTidyTuningValues(t runtimeconfig.MemoryTidyTuning) map[string]any {
|
||||
out := map[string]any{}
|
||||
if t.IntervalSec != nil {
|
||||
out["intervalSec"] = *t.IntervalSec
|
||||
}
|
||||
if t.QuietIntervalSec != nil {
|
||||
out["quietIntervalSec"] = *t.QuietIntervalSec
|
||||
}
|
||||
if t.QuietStartHour != nil {
|
||||
out["quietStartHour"] = *t.QuietStartHour
|
||||
}
|
||||
if t.QuietEndHour != nil {
|
||||
out["quietEndHour"] = *t.QuietEndHour
|
||||
}
|
||||
if t.IngestGate != nil {
|
||||
out["ingestGate"] = *t.IngestGate
|
||||
}
|
||||
if t.AutoApply != nil {
|
||||
out["autoApply"] = *t.AutoApply
|
||||
}
|
||||
if t.TempTtlSec != nil {
|
||||
out["tempTtlSec"] = *t.TempTtlSec
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件锁定记忆整理「面板调优」读写端点(`GET|PUT /api/v1/admin/memories/tidy/tuning`)
|
||||
// 的判据:
|
||||
// ① 读侧如实回显**盘上值**(键缺位 ⇒ 该键不出现)+ 取值规则 specs + `.env` 兜底值,
|
||||
// 且不回显同文件里不受管的键;
|
||||
// ② 写侧**三态**:键缺位 ⇒ 逐字节不动(含「不重写文件」);合法值 ⇒ 采用且保留
|
||||
// 同文件其它键;非法值 ⇒ `400 invalid_payload` 且**盘上一个字节都不动**;
|
||||
// ③ 盘上文件解不开 ⇒ 读/写都 `409 tuning_corrupt` 且绝不覆盖;
|
||||
// ④ `null` 是清除哨兵(删键 ⇒ 回到下一档兜底);
|
||||
// ⑤ RBAC 走 writeAuth(owner|admin)、写落审计 `memory.tidy.tuning`;
|
||||
// ⑥ 热生效**如实**:接线了 ⇒ hot=true 且真把值交给运行期;没接线/失败 ⇒ hot=false +
|
||||
// hotError,绝不把「落盘了」说成「生效了」。
|
||||
|
||||
// tuneEnvT 是一个注入了数据目录(= 面板配置文件落点所在)的网关测试环境。
|
||||
type tuneEnvT struct {
|
||||
srv *Server
|
||||
ts *httptest.Server
|
||||
dir string
|
||||
jwt *auth.JWTService
|
||||
}
|
||||
|
||||
// newTuneEnv 构造环境。`cfg.MemoryTidy` 被显式填上,用来验证读侧下发的
|
||||
// 「缺位时 `.env` 那一档」确实取自进程生效配置(而不是网关自己编的数)。
|
||||
func newTuneEnv(t *testing.T, deps Deps) *tuneEnvT {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfg := cfgDefault()
|
||||
cfg.MemoryTidy = config.MemoryTidyConfig{IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
|
||||
deps.DataDir = dir
|
||||
srv, ts, jwt := buildEnv(t, cfg, deps)
|
||||
return &tuneEnvT{srv: srv, ts: ts, dir: dir, jwt: jwt}
|
||||
}
|
||||
|
||||
func (e *tuneEnvT) url() string {
|
||||
return e.ts.URL + "/api/v1/admin/memories/tidy/tuning"
|
||||
}
|
||||
|
||||
func (e *tuneEnvT) path() string {
|
||||
return filepath.Join(e.dir, filepath.FromSlash(runtimeconfig.MemoryTidyConfigRelPath))
|
||||
}
|
||||
|
||||
func (e *tuneEnvT) token(t *testing.T, role auth.Role) string {
|
||||
t.Helper()
|
||||
return signAny(t, e.jwt, "acct-"+string(role), role)
|
||||
}
|
||||
|
||||
// seed 写入一份面板配置并返回写后的字节(后续逐字节比对用)。
|
||||
func (e *tuneEnvT) seed(t *testing.T, body string) []byte {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(e.path()), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(e.path(), []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
return e.bytes(t)
|
||||
}
|
||||
|
||||
// bytes 读回盘上的原始字节:「非法值不落盘」的证据就是它与之前相等。
|
||||
func (e *tuneEnvT) bytes(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(e.path())
|
||||
if err != nil {
|
||||
t.Fatalf("read tuning file: %v", err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func (e *tuneEnvT) put(t *testing.T, token, raw string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
return doRaw(t, http.MethodPut, e.url(), raw, token)
|
||||
}
|
||||
|
||||
func (e *tuneEnvT) get(t *testing.T, token string) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
return doReq(t, http.MethodGet, e.url(), nil, token)
|
||||
}
|
||||
|
||||
// view 解一次成功响应的 data。
|
||||
func (e *tuneEnvT) view(t *testing.T, body []byte) AdminMemoryTidyTuningView {
|
||||
t.Helper()
|
||||
var view AdminMemoryTidyTuningView
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &view); err != nil {
|
||||
t.Fatalf("decode view: %v (body=%s)", err, body)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// assertErrorCode 断言响应是错误信封且 code 匹配。
|
||||
func assertErrorCode(t *testing.T, body []byte, want string) {
|
||||
t.Helper()
|
||||
var eobj struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
|
||||
t.Fatalf("decode error: %v (body=%s)", err, body)
|
||||
}
|
||||
if eobj.Code != want {
|
||||
t.Fatalf("error code = %q, want %q (body=%s)", eobj.Code, want, body)
|
||||
}
|
||||
}
|
||||
|
||||
// diskDoc 解出盘上的文档(键 → 原样 JSON 值)。
|
||||
func (e *tuneEnvT) diskDoc(t *testing.T) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
doc := map[string]json.RawMessage{}
|
||||
if err := json.Unmarshal(e.bytes(t), &doc); err != nil {
|
||||
t.Fatalf("decode disk: %v", err)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyTuningRead_EchoesDiskValues 读侧:回显盘上 7 键(缺位不下发)、
|
||||
// 下发取值规则与 `.env` 兜底值,且**不回显**同文件里不受管的键。
|
||||
func TestAdminMemoryTidyTuningRead_EchoesDiskValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTuneEnv(t, Deps{})
|
||||
env.seed(t, `{
|
||||
"intervalSec": 3600,
|
||||
"quietStartHour": 23,
|
||||
"quietEndHour": 7,
|
||||
"ingestGate": false,
|
||||
"t1PerWindow": 120
|
||||
}`)
|
||||
resp, body := env.get(t, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("read status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
view := env.view(t, body)
|
||||
if view.Path != runtimeconfig.MemoryTidyConfigRelPath || !view.Present {
|
||||
t.Fatalf("view path/present = %q/%v", view.Path, view.Present)
|
||||
}
|
||||
if got := view.Values["intervalSec"]; got != float64(3600) {
|
||||
t.Fatalf("values.intervalSec = %#v, want 3600", got)
|
||||
}
|
||||
if got := view.Values["ingestGate"]; got != false {
|
||||
t.Fatalf("values.ingestGate = %#v, want false", got)
|
||||
}
|
||||
// 键缺位 ⇒ 不出现(「没配」与「配成 0」必须分得开)。
|
||||
for _, absent := range []string{"quietIntervalSec", "autoApply", "tempTtlSec"} {
|
||||
if _, ok := view.Values[absent]; ok {
|
||||
t.Fatalf("values 不该出现 %s(盘上没这个键):%#v", absent, view.Values)
|
||||
}
|
||||
}
|
||||
// 不受管的键(t1PerWindow)不进 values:这张卡改不了它,混进来会误导。
|
||||
if _, ok := view.Values["t1PerWindow"]; ok {
|
||||
t.Fatalf("values 不该出现不受管的 t1PerWindow:%#v", view.Values)
|
||||
}
|
||||
if len(view.Specs) != len(memoryTidyTuningManagedKeys()) {
|
||||
t.Fatalf("specs = %d 条, want %d", len(view.Specs), len(memoryTidyTuningManagedKeys()))
|
||||
}
|
||||
if view.EnvValues["intervalSec"] != 86400 || view.EnvValues["quietStartHour"] != 23 {
|
||||
t.Fatalf("envValues = %#v, want intervalSec=86400 / quietStartHour=23", view.EnvValues)
|
||||
}
|
||||
if view.Hot {
|
||||
t.Fatal("只读响应里 hot 必须为 false(读不改变任何东西)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyTuningRead_MissingFileIsNotAnError 文件还没播种时:正常回 200、
|
||||
// present=false、values 为空 —— 「还没配过」不是错误(与 runtimeconfig 的读取口径一致)。
|
||||
func TestAdminMemoryTidyTuningRead_MissingFileIsNotAnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTuneEnv(t, Deps{})
|
||||
resp, body := env.get(t, env.token(t, auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
view := env.view(t, body)
|
||||
if view.Present || len(view.Values) != 0 {
|
||||
t.Fatalf("view = %+v, want present=false / 无值", view)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyTuningRead_ViewerForbidden RBAC:viewer 403、匿名 401。
|
||||
func TestAdminMemoryTidyTuningRead_ViewerForbidden(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTuneEnv(t, Deps{})
|
||||
env.seed(t, `{"intervalSec": 3600}`)
|
||||
resp, body := env.get(t, env.token(t, auth.RoleViewer))
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("viewer read status = %d, want 403 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
resp, _ = env.get(t, "")
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("anonymous read status = %d, want 401", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyTuningWrite_AdoptsValidValuesAndKeepsOtherKeys 写侧「合法值 ⇒ 采用」:
|
||||
// 7 键都能落盘,同文件里不受管的键**原样保留**(一次面板保存绝不抹掉别的旋钮)。
|
||||
func TestAdminMemoryTidyTuningWrite_AdoptsValidValuesAndKeepsOtherKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTuneEnv(t, Deps{})
|
||||
env.seed(t, `{"t1Judge": false, "t1PerWindow": 120}`)
|
||||
// 用 admin 角色也走同一道门(writeAuth = owner|admin)。
|
||||
resp, body := env.put(t, env.token(t, auth.RoleAdmin), `{
|
||||
"intervalSec": 10800,
|
||||
"quietIntervalSec": 3600,
|
||||
"quietStartHour": 23,
|
||||
"quietEndHour": 7,
|
||||
"ingestGate": true,
|
||||
"autoApply": true,
|
||||
"tempTtlSec": 604800
|
||||
}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("write status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
view := env.view(t, body)
|
||||
for key, want := range map[string]any{
|
||||
"intervalSec": float64(10800), "quietIntervalSec": float64(3600),
|
||||
"quietStartHour": float64(23), "quietEndHour": float64(7),
|
||||
"ingestGate": true, "autoApply": true, "tempTtlSec": float64(604800),
|
||||
} {
|
||||
if got := view.Values[key]; got != want {
|
||||
t.Fatalf("回显 values[%s] = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
doc := env.diskDoc(t)
|
||||
if string(doc["intervalSec"]) != "10800" || string(doc["t1PerWindow"]) != "120" {
|
||||
t.Fatalf("disk = %s", string(env.bytes(t)))
|
||||
}
|
||||
if _, ok := doc["t1Judge"]; !ok {
|
||||
t.Fatalf("不受管的 t1Judge 被抹掉了:%s", string(env.bytes(t)))
|
||||
}
|
||||
// 未接运行期 ⇒ 如实说「已落盘、重启后生效」。
|
||||
if view.Hot || view.HotError == "" {
|
||||
t.Fatalf("未接热生效时 hot/hotError = %v/%q, want false/非空", view.Hot, view.HotError)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched 三态之一:键**缺位** ⇒
|
||||
// 不改动。空补丁连文件都不重写(mtime 都不动);只提交一个键时另一个键逐字节不动。
|
||||
func TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTuneEnv(t, Deps{})
|
||||
before := env.seed(t, `{"intervalSec": 86400, "quietStartHour": 23}`)
|
||||
past := time.Now().Add(-time.Hour)
|
||||
if err := os.Chtimes(env.path(), past, past); err != nil {
|
||||
t.Fatalf("chtimes: %v", err)
|
||||
}
|
||||
tok := env.token(t, auth.RoleOwner)
|
||||
resp, body := env.put(t, tok, `{}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("empty patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if after := env.bytes(t); string(after) != string(before) {
|
||||
t.Fatalf("空补丁改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
|
||||
}
|
||||
info, err := os.Stat(env.path())
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if !info.ModTime().Equal(past) {
|
||||
t.Fatalf("空补丁重写了文件(mtime %v ⇒ %v)", past, info.ModTime())
|
||||
}
|
||||
if resp, body := env.put(t, tok, `{"quietStartHour": 22}`); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("partial patch status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
doc := env.diskDoc(t)
|
||||
if string(doc["intervalSec"]) != "86400" || string(doc["quietStartHour"]) != "22" {
|
||||
t.Fatalf("partial patch 结果 = %s", string(env.bytes(t)))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyTuningWrite_InvalidValueNeverTouchesDisk 三态之三(本文件的核心
|
||||
// 证据):任何非法输入都 `400 invalid_payload`,且**盘上一个字节都不动**。
|
||||
func TestAdminMemoryTidyTuningWrite_InvalidValueNeverTouchesDisk(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct{ name, body string }{
|
||||
{"字符串冒充整数", `{"intervalSec": "3600"}`},
|
||||
{"小数不是整数", `{"tempTtlSec": 1.5}`},
|
||||
{"周期为 0(会让整理空转)", `{"intervalSec": 0}`},
|
||||
{"周期为负", `{"quietIntervalSec": -60}`},
|
||||
{"钟点越上界", `{"quietStartHour": 24}`},
|
||||
{"钟点越下界", `{"quietEndHour": -1}`},
|
||||
{"钟点不是数字", `{"quietEndHour": "07"}`},
|
||||
{"开关不是布尔", `{"ingestGate": "yes"}`},
|
||||
{"未知键(拼错的 quietEnd)", `{"quietEnd": 7}`},
|
||||
{"请求体不是对象", `[1, 2]`},
|
||||
{"请求体是 null", `null`},
|
||||
{"请求体是空串", ``},
|
||||
{"合法键与非法键同时提交", `{"intervalSec": 3600, "autoApply": "on"}`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env := newTuneEnv(t, Deps{})
|
||||
before := env.seed(t, `{
|
||||
"intervalSec": 86400,
|
||||
"quietStartHour": 23,
|
||||
"quietEndHour": 7,
|
||||
"t1Judge": false
|
||||
}`)
|
||||
resp, body := env.put(t, env.token(t, auth.RoleOwner), tc.body)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
assertErrorCode(t, body, "invalid_payload")
|
||||
if after := env.bytes(t); string(after) != string(before) {
|
||||
t.Fatalf("非法输入改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
|
||||
}
|
||||
})
|
||||
}
|
||||
// 盘上**没有**文件时,非法输入也不得创建它(「先落盘再校验」同样是落盘)。
|
||||
t.Run("文件不存在时也不创建", func(t *testing.T) {
|
||||
env := newTuneEnv(t, Deps{})
|
||||
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 0}`)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if _, err := os.Stat(env.path()); !os.IsNotExist(err) {
|
||||
t.Fatalf("非法输入创建了文件:err=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyTuningWrite_NullClearsKey 清除哨兵:`null` 删掉该键(回到下一档
|
||||
// 兜底),同文件其它键不受影响。
|
||||
func TestAdminMemoryTidyTuningWrite_NullClearsKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTuneEnv(t, Deps{})
|
||||
env.seed(t, `{"intervalSec": 3600, "quietIntervalSec": 7200, "t1Judge": false}`)
|
||||
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": null}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("clear status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
view := env.view(t, body)
|
||||
if _, ok := view.Values["intervalSec"]; ok {
|
||||
t.Fatalf("清除后不该再回显 intervalSec:%#v", view.Values)
|
||||
}
|
||||
if got := view.Values["quietIntervalSec"]; got != float64(7200) {
|
||||
t.Fatalf("清除一个键影响了别的键:%#v", view.Values)
|
||||
}
|
||||
doc := env.diskDoc(t)
|
||||
if _, ok := doc["intervalSec"]; ok {
|
||||
t.Fatalf("盘上仍有 intervalSec:%s", string(env.bytes(t)))
|
||||
}
|
||||
if _, ok := doc["t1Judge"]; !ok {
|
||||
t.Fatalf("清除一个键抹掉了别的键:%s", string(env.bytes(t)))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyTuningWrite_RefusesCorruptFile 盘上那份运行期读不走时,**读与写
|
||||
// 都**拒绝(`409 tuning_corrupt`)且绝不覆盖 —— 否则面板会说「已保存」而运行期仍在
|
||||
// 整体回落 `.env` 并记 WARN。
|
||||
func TestAdminMemoryTidyTuningWrite_RefusesCorruptFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct{ name, body string }{
|
||||
{"类型写错", `{"intervalSec": "3600"}`},
|
||||
{"根本不是 JSON", `not json at all`},
|
||||
{"顶层不是对象", `[1, 2, 3]`},
|
||||
} {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
env := newTuneEnv(t, Deps{})
|
||||
before := env.seed(t, tc.body)
|
||||
tok := env.token(t, auth.RoleOwner)
|
||||
resp, body := env.put(t, tok, `{"intervalSec": 3600}`)
|
||||
if resp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("write status = %d, want 409 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
assertErrorCode(t, body, "tuning_corrupt")
|
||||
if string(env.bytes(t)) != string(before) {
|
||||
t.Fatalf("坏文件被覆盖了:%s", string(env.bytes(t)))
|
||||
}
|
||||
// 读侧同样如实报坏,而不是回一份空配置假装「没配过」。
|
||||
rresp, rbody := env.get(t, tok)
|
||||
if rresp.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("read status = %d, want 409 (body=%s)", rresp.StatusCode, rbody)
|
||||
}
|
||||
assertErrorCode(t, rbody, "tuning_corrupt")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyTuningWrite_SameValueDoesNotRewrite 「保存同样的值」不该在盘上留
|
||||
// 一次无意义的改写(内容逐字节相同 ⇒ 不落盘、mtime 不动)。
|
||||
func TestAdminMemoryTidyTuningWrite_SameValueDoesNotRewrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newTuneEnv(t, Deps{})
|
||||
env.seed(t, `{"intervalSec": 3600}`)
|
||||
past := time.Now().Add(-time.Hour)
|
||||
if err := os.Chtimes(env.path(), past, past); err != nil {
|
||||
t.Fatalf("chtimes: %v", err)
|
||||
}
|
||||
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 3600}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
info, err := os.Stat(env.path())
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if !info.ModTime().Equal(past) {
|
||||
t.Fatalf("同值保存重写了文件(mtime %v ⇒ %v)", past, info.ModTime())
|
||||
}
|
||||
if string(env.bytes(t)) != `{"intervalSec": 3600}` {
|
||||
t.Fatalf("盘上内容变了:%s", string(env.bytes(t)))
|
||||
}
|
||||
}
|
||||
|
||||
// fakeTuningTidy 既是整理服务的只读出口,也是「可热生效」的实现(记录收到的值)。
|
||||
type fakeTuningTidy struct {
|
||||
fakeTidyDryRunner
|
||||
gotBase *memory.TidyConfig
|
||||
gotTuning *runtimeconfig.MemoryTidyTuning
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTuningTidy) ApplyPanelTuning(base memory.TidyConfig, panel runtimeconfig.MemoryTidyTuning) error {
|
||||
f.gotBase = &base
|
||||
f.gotTuning = &panel
|
||||
return f.err
|
||||
}
|
||||
|
||||
// fakeTuningMemories 同时实现 MemoryStore 与「入库侧可热生效」。
|
||||
type fakeTuningMemories struct {
|
||||
got *runtimeconfig.MemoryTidyTuning
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeTuningMemories) List(context.Context) ([]memory.MemoryItem, error) {
|
||||
return []memory.MemoryItem{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTuningMemories) ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
|
||||
f.got = &panel
|
||||
return f.err
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyTuningWrite_HotAppliesAndAudits 接线了热生效实现时:保存后当场把
|
||||
// **新值**交给运行期(hot=true),并落一条审计 `memory.tidy.tuning`/ok。
|
||||
func TestAdminMemoryTidyTuningWrite_HotAppliesAndAudits(t *testing.T) {
|
||||
t.Parallel()
|
||||
tidy := &fakeTuningTidy{}
|
||||
mems := &fakeTuningMemories{}
|
||||
env := newTuneEnv(t, Deps{MemoryTidy: tidy, Memories: mems})
|
||||
env.seed(t, `{"intervalSec": 86400}`)
|
||||
resp, body := env.put(t, env.token(t, auth.RoleAdmin), `{"intervalSec": 10800, "tempTtlSec": 259200}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
view := env.view(t, body)
|
||||
if !view.Hot || view.HotError != "" {
|
||||
t.Fatalf("hot/hotError = %v/%q, want true/空", view.Hot, view.HotError)
|
||||
}
|
||||
if tidy.gotTuning == nil || tidy.gotTuning.IntervalSec == nil || *tidy.gotTuning.IntervalSec != 10800 {
|
||||
t.Fatalf("整理档没收到面板值:%#v", tidy.gotTuning)
|
||||
}
|
||||
if mems.got == nil || mems.got.TempTtlSec == nil || *mems.got.TempTtlSec != 259200 {
|
||||
t.Fatalf("入库侧没收到面板值:%#v", mems.got)
|
||||
}
|
||||
// base 是 `.env` 层(进程生效配置投影),不是面板值本身。
|
||||
if tidy.gotBase == nil || tidy.gotBase.IntervalSec != 86400 {
|
||||
t.Fatalf("base 不是 .env 层:%#v", tidy.gotBase)
|
||||
}
|
||||
recs, err := env.srv.obs.audit.List(context.Background(), 50)
|
||||
if err != nil {
|
||||
t.Fatalf("list audit: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, rec := range recs {
|
||||
if rec.What == memoryTuningAuditAction && rec.Result == "ok" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("审计链里没有 %s/ok 的记录:%#v", memoryTuningAuditAction, recs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyTuningWrite_HotFailureIsHonest 热生效实现报错时:**值照旧留在盘上**
|
||||
// (不丢),但响应如实 `hot:false` + 原因,绝不说成「已生效」。
|
||||
func TestAdminMemoryTidyTuningWrite_HotFailureIsHonest(t *testing.T) {
|
||||
t.Parallel()
|
||||
tidy := &fakeTuningTidy{err: errors.New("boom")}
|
||||
env := newTuneEnv(t, Deps{MemoryTidy: tidy})
|
||||
env.seed(t, `{"intervalSec": 86400}`)
|
||||
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 10800}`)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
view := env.view(t, body)
|
||||
if view.Hot || view.HotError == "" {
|
||||
t.Fatalf("hot/hotError = %v/%q, want false/非空", view.Hot, view.HotError)
|
||||
}
|
||||
if got := view.Values["intervalSec"]; got != float64(10800) {
|
||||
t.Fatalf("热生效失败不该丢值:%#v", view.Values)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyTuning_UnavailableWithoutDataDir 数据目录未接线时如实报「没有落点」,
|
||||
// 绝不猜一个目录去写(端点据此回 500 tuning_unavailable)。
|
||||
func TestAdminMemoryTidyTuning_UnavailableWithoutDataDir(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv, err := New(cfgDefault(), Deps{
|
||||
Auth: auth.New(config.AuthConfig{AdminUser: "a", AdminPass: "b", JWTSecret: testSecret}),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
if _, ok := srv.memoryTidyTuningPath(); ok {
|
||||
t.Fatal("没有数据目录时不该解析出落点")
|
||||
}
|
||||
}
|
||||
@@ -259,6 +259,16 @@ func (s *Server) registerAdminRoutes(
|
||||
// 幂等;审计 `memory.tidy.reject`。见 admin_memory_tidy_reject.go。
|
||||
mux.Handle("POST /api/v1/admin/memories/candidates/reject", writeAuth(http.HandlerFunc(s.adminTidyCandidatesReject)))
|
||||
mux.Handle("POST /api/v1/admin/memories/tidy/run", writeAuth(http.HandlerFunc(s.adminMemoryTidyRun)))
|
||||
// 记忆整理「面板调优」的读写(切片 MEMPANEL / M4;契约由塔登记):
|
||||
// 读 .../memories/tidy/tuning 回显 `$DATA_DIR/config/memory-tidy.json` 里本卡片
|
||||
// 管的 7 个旋钮 + 它们的取值规则(specs);写同一条路径是**三态补丁**
|
||||
// (键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上不动)+ 原子落盘
|
||||
// + 审计(memory.tidy.tuning)。两条都走 writeAuth(owner|admin)—— 与同族的
|
||||
// GET /admin/memories、POST /admin/memories/{id}/revert 同档:它是私有记忆面的
|
||||
// 运行期旋钮,不是给 viewer 的监控投影;也**不是** owner-only(写的是面板配置
|
||||
// 文件,不是身份/凭据面)。见 admin_memory_tuning.go。
|
||||
mux.Handle("GET /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningRead)))
|
||||
mux.Handle("PUT /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningWrite)))
|
||||
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
|
||||
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
|
||||
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))
|
||||
|
||||
Reference in new issue
Block a user