feat(memory): 启动时自动播种面板配置文件(缺失才写、绝不覆盖)
主人 2026-10-07:「后端自动创建独立的面板配置文件要做」。 - 启动路径(wireMemoryTidy → applyMemoryTidyTuning 之后)若 $DATA_DIR/config/memory-tidy.json 不存在或全空白 => 写一份「当时生效值」 (十键:既有六键 + 频率四键),此后面板接管 - 已存在(非空)=> 跳过,该分支没有任何写盘语句;损坏 => WARN + 按 .env 跑、 一个字不写盘(不「修好它」) - 幂等(第二次启动必落到跳过分支);原子写(tmp 0600 + rename) - 落盘前完整性闸:缺键 / 非正周期 / 钟点越界 => WARN + 不落盘 - 值来自 TidyTuningSnapshot(先跑两个归一化再翻回正向键)=> 播种那一刻运行 行为零变化,有强断言(归一化后配置逐字相等) - 零新增依赖、零 env 键变更;5 个变异(含「让已存在走写盘」=> 红)
This commit is contained in:
1 parent
d680390300
commit
23b6e3fe1e
8 files changed
+1156
-6
No files matched your search
@@ -44,13 +44,16 @@
|
||||
//
|
||||
// 1. 本文件只管**文件形状 + 读取**:它不认识 `TidyConfig`,也不认识「零值回落
|
||||
// 代码默认值」那条裁决 —— 那是 `memory.normalizeTidyConfig` 的事。
|
||||
// 2. **只读**:面板写路径不在本切片内,因此刻意没有 `Save*`。改动仍要
|
||||
// **人手改这个文件 + 重启**(与 `backthink.json` 同口径:启动期读一次)。
|
||||
// 2. **没有「面板编辑」的写口**:改内容仍要**人手改这个文件 + 重启**(与
|
||||
// `backthink.json` 同口径:启动期读一次)。唯一的写路径是**启动播种**
|
||||
// (memorytidy_seed.go)—— 它只在文件缺失/空白时写一次,之后一个字节都不动;
|
||||
// 面板上的编辑口是另一片,不在本文件。
|
||||
// 3. **零新 env 键**,也不进面板的配置事实卡(那张表的键名被 `.env.example` 钉住)。
|
||||
//
|
||||
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**);存在但解不开
|
||||
// = 返回错误,调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关
|
||||
// 或上限读成「无限制」)。
|
||||
// 读取口径:文件缺失或全空白 = 还没配过(零值 + nil,**不是错误**;启动时这种情形
|
||||
// 会被播种成一份「当时生效值」,见 memorytidy_seed.go);存在但解不开 = 返回错误,
|
||||
// 调用方记 WARN 并回落**代码默认值**(即:坏配置绝不能把自动档的开关或上限读成
|
||||
// 「无限制」)。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
@@ -203,6 +206,16 @@ func LoadMemoryTidyTuning(path string) (MemoryTidyTuning, error) {
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
return MemoryTidyTuning{}, nil
|
||||
}
|
||||
return decodeMemoryTidy(raw, path)
|
||||
}
|
||||
|
||||
// decodeMemoryTidy 是 LoadMemoryTidyTuning 与**启动播种**(memorytidy_seed.go)共用的
|
||||
// 解码点:把「解不开」统一分类成 ErrInvalidMemoryTidy。两处对「坏文件」的判定必须
|
||||
// 出自同一行代码,否则会出现「读取说坏了、播种说没事」这种自相矛盾(播种侧要靠这个
|
||||
// 结论决定「绝不覆盖」,判错的代价是一份人工配置被盖掉)。
|
||||
//
|
||||
// 空内容由调用方先滤掉(空 = 还没配过,与「缺失」同义,不是解码失败)。
|
||||
func decodeMemoryTidy(raw []byte, path string) (MemoryTidyTuning, error) {
|
||||
var t MemoryTidyTuning
|
||||
if err := json.Unmarshal(raw, &t); err != nil {
|
||||
return MemoryTidyTuning{}, fmt.Errorf("%w: decode %s: %w", ErrInvalidMemoryTidy, path, err)
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// memorytidy_seed.go 落地主人 2026-10-07 那句「**`.env` 只做兜底用** —— 后端**自动
|
||||
// 创建**独立的面板配置文件**要做**」里的「自动创建」:进程启动时若
|
||||
// `$DATA_DIR/config/memory-tidy.json` **不存在**,就按**当时生效的值**播种一份,
|
||||
// 使这个家族从此有一份自足的一等来源,而不是每次都靠 `.env` 兜。
|
||||
//
|
||||
// 与 memorytidy.go(读取)的边界同源:本文件只管**文件形状与落盘**,不认识
|
||||
// TidyConfig / ReviewConfig,也不知道任何一个默认值是多少 —— 那属于 memory 侧
|
||||
// normalize 的职责。播种内容由调用方给出(`memory.TidyTuningSnapshot` 拍出的
|
||||
// 「当前生效值」快照),与 materialize.go 拿到的是 `.env` 合成组同一手法。
|
||||
//
|
||||
// 四条纪律,前三条逐字对齐 materialize.go(那是本仓「`.env` → 面板文件」播种的既有
|
||||
// 施工口径,本文件是同一套搬到 memory-tidy 家族):
|
||||
//
|
||||
// 1. **绝不覆盖已存在的文件**:文件在(无论内容是什么、无论解不解得开)⇒ 一个字节
|
||||
// 都不动。这条是铁律 —— 主人手改过的文件被程序盖回去,就是「面板改一次、重启
|
||||
// 一次被打回」那个失败形态。
|
||||
// 2. **损坏不是要「修好」的状态**:解不开时只记 WARN、按 `.env` 跑、**不写盘**。
|
||||
// 人工写坏的配置可以人工救回,程序盖掉它就丢了那份人工意图。
|
||||
// 3. **幂等**:只有「文件不存在(或全空白)」这一次会落盘;连续启动多次,第二次起
|
||||
// 是空操作(文件已存在 ⇒ 走第 1 条)。
|
||||
// 4. **不阻断启动**:返回的 error 只在「该写却没写成」时非 nil(写盘失败 / 种子
|
||||
// 残缺);调用方记一条 WARN 即可 —— 播种失败时运行期仍按 `.env` + 代码默认跑,
|
||||
// 行为与播种前逐字相同。
|
||||
//
|
||||
// 日志**只打路径、原因与键名,不打值**:这十键今天都不是凭据,但「配置日志不打值」
|
||||
// 是本仓既有口径(materialize.go 的「绝不打 apiKey」),这里从一开始就照做,免得
|
||||
// 日后加一个敏感键时要回头改日志。
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
// memoryTidySeedReasonMissing / ...Blank / ...Exists 是播种日志的 reason 字段值
|
||||
// (与 materialize.go 的 reason 口径一致:让人一眼看出是「没有」「空」还是
|
||||
// 「已在、没动它」)。
|
||||
memoryTidySeedReasonMissing = "面板配置不存在"
|
||||
memoryTidySeedReasonBlank = "面板配置为空文件"
|
||||
memoryTidySeedReasonExists = "面板配置已存在,未改动"
|
||||
// memoryTidySeedFileMode 是播种文件的权限。0600 与 SaveModels / SavePlugins
|
||||
// 同口径:配置目录可能放凭据,一律不给组/其他读权限。
|
||||
memoryTidySeedFileMode = 0o600
|
||||
// memoryTidySeedDirMode 是新建 `$DATA_DIR/config/` 时的目录权限,与 SaveModels
|
||||
// 的 mkdir 口径一致。
|
||||
memoryTidySeedDirMode = 0o755
|
||||
)
|
||||
|
||||
// memoryTidySeedKeys 是播种文件应有的键名全集(顺序即 JSON 顺序)。它用于播种日志
|
||||
// 的「播了哪些键」,并与结构体的 json tag 相互钉住(见
|
||||
// TestMemoryTidySeedKeysMatchStruct):加了新键却忘了更新它,用例会红。
|
||||
var memoryTidySeedKeys = []string{
|
||||
"autoApply", "autoApplyPerRound", "ingestGate", "t1Judge",
|
||||
"t1PerWindow", "t1WindowSec", "intervalSec", "quietIntervalSec",
|
||||
"quietStartHour", "quietEndHour",
|
||||
}
|
||||
|
||||
// MemoryTidySeed 是一次播种的可诊断产物(口径对齐 materialize.go 的 Result:
|
||||
// Written / Warn 的含义逐字相同,Existed 用来区分「跳过」的起因)。
|
||||
type MemoryTidySeed struct {
|
||||
// Written 为 true 表示本次真的落盘(只在文件缺失/全空白时可能为 true)。
|
||||
Written bool
|
||||
// Existed 为 true 表示文件已存在(含解不开的),本次一个字节都没动。
|
||||
// 它与 Written 互斥。
|
||||
Existed bool
|
||||
// Warn 是非致命异常:文件存在但解不开(按 `.env` 跑,且不写盘)。
|
||||
Warn error
|
||||
}
|
||||
|
||||
// SeedMemoryTidy 在 path 处播种一份面板配置 —— **仅当它缺失或全空白**。
|
||||
//
|
||||
// 「全空白」与「缺失」同义是既有口径(LoadMemoryTidyTuning 把两者都读成「还没配过」
|
||||
// ⇒ 零值 ⇒ 走 `.env` 兜底),因此对空白文件播种,那一刻的生效值一个字都不变 ——
|
||||
// 与 materialize.go 对空文件的处置一致。
|
||||
//
|
||||
// `null` **不算空白**:它解得出零值,但它是「盘上有一个文件」这个事实本身,按铁律
|
||||
// 不动它(主人的口径是「不存在才自动创建」,不是「内容看着空就重建」)。
|
||||
//
|
||||
// log 由调用方给出(组合根一律传带 source 的日志器):本函数会直接往它上面写日志,
|
||||
// 因此**不要传 nil**。
|
||||
func SeedMemoryTidy(path string, seed MemoryTidyTuning, log *slog.Logger) (MemoryTidySeed, error) {
|
||||
log = logger.WithSource(log, "config/runtimeconfig")
|
||||
raw, readErr := os.ReadFile(path)
|
||||
if readErr == nil {
|
||||
return seedMemoryTidyExisting(log, path, raw, seed)
|
||||
}
|
||||
if !os.IsNotExist(readErr) {
|
||||
log.Warn("memory-tidy.json 不可读,本次跳过播种(按 .env 兜底运行)", "path", path, "error", readErr)
|
||||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: 读取 %s: %w", path, readErr)
|
||||
}
|
||||
return writeMemoryTidySeed(log, path, seed, memoryTidySeedReasonMissing)
|
||||
}
|
||||
|
||||
// seedMemoryTidyExisting 处理「文件已存在」的三种形态,三种处置都**不覆盖**:
|
||||
//
|
||||
// - 解不开 ⇒ WARN + 按 `.env` 跑(绝不当场「修好」它,见文件头第 2 条);
|
||||
// - 全空白 ⇒ 按「还没配过」播种(唯一会在文件已存在时写盘的形态,理由见
|
||||
// SeedMemoryTidy 的说明);
|
||||
// - 其余(含 `null`)⇒ 跳过。
|
||||
func seedMemoryTidyExisting(log *slog.Logger, path string, raw []byte, seed MemoryTidyTuning) (MemoryTidySeed, error) {
|
||||
if strings.TrimSpace(string(raw)) == "" {
|
||||
log.Info("memory-tidy.json 是空文件(按「还没配过」处理),准备播种", "path", path)
|
||||
return writeMemoryTidySeed(log, path, seed, memoryTidySeedReasonBlank)
|
||||
}
|
||||
if _, err := decodeMemoryTidy(raw, path); err != nil {
|
||||
// 解不开 ≠ 可以盖掉:只留 WARN,一个字都不写盘。这条 WARN 是主人可查的
|
||||
// 唯一线索 —— 「这份配置没生效、也没被程序改过」。
|
||||
log.Warn("memory-tidy.json 无法解析,本次不播种也不覆盖原文件(按 .env 兜底运行)",
|
||||
"path", path, "error", err)
|
||||
return MemoryTidySeed{Existed: true, Warn: err}, nil
|
||||
}
|
||||
// 跳过也留痕(Info 而不是 Debug:这是**每次启动一行**的交接回执 —— 主人能从日志
|
||||
// 确认「后端认得我手写的那份文件、没有动它」,而不是只有提升日志级别才看得见)。
|
||||
log.Info("memory-tidy.json 已存在,跳过播种(面板配置优先,绝不覆盖原文件)",
|
||||
"path", path, "reason", memoryTidySeedReasonExists)
|
||||
return MemoryTidySeed{Existed: true}, nil
|
||||
}
|
||||
|
||||
// writeMemoryTidySeed 真正落盘:先把 `$DATA_DIR/config/` 建出来(不存在时),再按
|
||||
// 「临时文件 + rename」原子写(同 SaveModels / SaveWebSearch:并发读者绝不会看到
|
||||
// 半个 JSON),权限 0600。
|
||||
//
|
||||
// 落盘前先验种子**完整**(见 memoryTidySeedComplete):一份缺键的快照写下去会变成
|
||||
// `"intervalSec": null` —— 读回来语义上等于「没配」,但主人打开文件会看到一堆 null,
|
||||
// 且「播种 = 当时的生效值」这句话就不再成立。残缺的种子是调用方的编程错误,宁可
|
||||
// WARN + 不落盘,也不留一份自相矛盾的文件。
|
||||
func writeMemoryTidySeed(log *slog.Logger, path string, seed MemoryTidyTuning, reason string) (MemoryTidySeed, error) {
|
||||
if err := memoryTidySeedComplete(seed); err != nil {
|
||||
log.Warn("memory-tidy.json 播种内容不完整,本次不落盘(.env 继续兜底)", "path", path, "error", err)
|
||||
return MemoryTidySeed{}, err
|
||||
}
|
||||
raw, err := json.MarshalIndent(seed, "", " ")
|
||||
if err != nil {
|
||||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: marshal memory tidy seed: %w", err)
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, memoryTidySeedDirMode); err != nil {
|
||||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, raw, memoryTidySeedFileMode); err != nil {
|
||||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return MemoryTidySeed{}, fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
|
||||
}
|
||||
log.Info("memory-tidy.json 已按当时生效值播种落盘(此后以面板配置为准,.env 只兜底)",
|
||||
"path", path, "reason", reason, "keys", memoryTidySeedKeys)
|
||||
return MemoryTidySeed{Written: true}, nil
|
||||
}
|
||||
|
||||
// memoryTidySeedComplete 报告种子是否「十键齐全且值可用」。
|
||||
//
|
||||
// 为什么要「齐全」而不是「非 nil 就放行」:MemoryTidyTuning 的指针语义是「nil = 没配」,
|
||||
// 一份缺键的种子写下去虽然与「不写」在读取端等价,但它会让文件里出现 null —— 这份文件
|
||||
// 的主人(主人 / 面板)看到的东西必须与「生效值」逐字一致。
|
||||
//
|
||||
// 数值键要求为**正**:`intervalSec: 0` 在 memory 侧是**坏值**(非正周期让心跳空转,
|
||||
// 见 ApplyTidyScheduleTuning),播种出一份自己读回都要 WARN 的配置是自相矛盾的;
|
||||
// 钟点则要求落在 0..23(越界会让 time.Date 静默滚动到别的一天)。
|
||||
func memoryTidySeedComplete(seed MemoryTidyTuning) error {
|
||||
missing := make([]string, 0, len(memoryTidySeedKeys))
|
||||
if seed.AutoApply == nil {
|
||||
missing = append(missing, "autoApply")
|
||||
}
|
||||
if seed.IngestGate == nil {
|
||||
missing = append(missing, "ingestGate")
|
||||
}
|
||||
if seed.T1Judge == nil {
|
||||
missing = append(missing, "t1Judge")
|
||||
}
|
||||
if seed.IntervalSec == nil {
|
||||
missing = append(missing, "intervalSec")
|
||||
}
|
||||
if seed.QuietIntervalSec == nil {
|
||||
missing = append(missing, "quietIntervalSec")
|
||||
}
|
||||
if seed.QuietStartHour == nil {
|
||||
missing = append(missing, "quietStartHour")
|
||||
}
|
||||
if seed.QuietEndHour == nil {
|
||||
missing = append(missing, "quietEndHour")
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed missing keys: %s", strings.Join(missing, ", "))
|
||||
}
|
||||
if seed.AutoApplyPerRound <= 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed autoApplyPerRound = %d (want > 0)", seed.AutoApplyPerRound)
|
||||
}
|
||||
if seed.T1PerWindow <= 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed t1PerWindow = %d (want > 0)", seed.T1PerWindow)
|
||||
}
|
||||
if seed.T1WindowSec <= 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed t1WindowSec = %d (want > 0)", seed.T1WindowSec)
|
||||
}
|
||||
if *seed.IntervalSec <= 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed intervalSec = %d (want > 0)", *seed.IntervalSec)
|
||||
}
|
||||
if *seed.QuietIntervalSec <= 0 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed quietIntervalSec = %d (want > 0)", *seed.QuietIntervalSec)
|
||||
}
|
||||
if *seed.QuietStartHour < 0 || *seed.QuietStartHour > 23 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed quietStartHour = %d (want 0..23)", *seed.QuietStartHour)
|
||||
}
|
||||
if *seed.QuietEndHour < 0 || *seed.QuietEndHour > 23 {
|
||||
return fmt.Errorf("runtimeconfig: memory tidy seed quietEndHour = %d (want 0..23)", *seed.QuietEndHour)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 本文件锁定**启动播种**(memorytidy_seed.go;主人 2026-10-07:「**`.env` 只做兜底
|
||||
// 用** …… 后端**自动创建**独立的面板配置文件」)的四条纪律:
|
||||
//
|
||||
// ① 文件不存在 ⇒ 按调用方给的「当前生效值」播种(**十键齐全**,不是只播老六键);
|
||||
// ② 文件已存在 ⇒ **一个字节都不动**(也不留 `.tmp`);
|
||||
// ③ 文件损坏 ⇒ **不写盘**(WARN + 按 `.env` 跑),绝不「修好它」;
|
||||
// ④ 幂等:连跑两次只在第一次写盘。
|
||||
//
|
||||
// 用例全部落在**临时目录**(`t.TempDir()`):生产那份 `data/config/memory-tidy.json`
|
||||
// 是主人手工建的,播种逻辑只在文件缺失时触发,用例不许依赖它存在与否。
|
||||
|
||||
// completeSeedTuning 造一份「十键齐全」的播种内容。值刻意都**不是**代码默认值
|
||||
// (autoApply=false / 3600 / 10800),这样「播的是调用方给的快照」与「播了一份硬编码
|
||||
// 的默认值」在断言里一眼可分。
|
||||
func completeSeedTuning() MemoryTidyTuning {
|
||||
return MemoryTidyTuning{
|
||||
AutoApply: boolSeedPtr(false),
|
||||
AutoApplyPerRound: 7,
|
||||
IngestGate: boolSeedPtr(true),
|
||||
T1Judge: boolSeedPtr(false),
|
||||
T1PerWindow: 240,
|
||||
T1WindowSec: 1800,
|
||||
IntervalSec: intSeedPtr(3600),
|
||||
QuietIntervalSec: intSeedPtr(10800),
|
||||
QuietStartHour: intSeedPtr(23),
|
||||
QuietEndHour: intSeedPtr(7),
|
||||
}
|
||||
}
|
||||
|
||||
// boolSeedPtr / intSeedPtr 造「快照里显式写了这个键」的指针。
|
||||
func boolSeedPtr(v bool) *bool { return &v }
|
||||
|
||||
func intSeedPtr(v int) *int { return &v }
|
||||
|
||||
// seedPath 返回一个**尚不存在**的落点(含尚不存在的 `config/` 目录)。
|
||||
func seedPath(t *testing.T) string {
|
||||
t.Helper()
|
||||
return filepath.Join(t.TempDir(), "config", memoryTidyFileName)
|
||||
}
|
||||
|
||||
// decodeSeed 把盘上的文件解成键集合,供「十键齐全」的断言(比结构体更贴近主人看到
|
||||
// 的那份文件)。
|
||||
func decodeSeed(t *testing.T, path string) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(readRaw(t, path)), &keys); err != nil {
|
||||
t.Fatalf("解 %s 的键集合: %v", path, err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// assertNoTempLeft 断言播种没留下临时文件(原子写的中间态不该留在盘上)。
|
||||
func assertNoTempLeft(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
|
||||
t.Fatalf("留下了临时文件 %s.tmp", path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_SeedsMissingFile 是本任务的主用例:文件不存在 ⇒ 播种,且**内容
|
||||
// 就是调用方给的快照**(十键齐全、含四项频率键),目录也一并建出来。
|
||||
func TestSeedMemoryTidy_SeedsMissingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := seedPath(t) // 连 config/ 目录都不存在
|
||||
seed := completeSeedTuning()
|
||||
|
||||
res, err := SeedMemoryTidy(path, seed, discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("SeedMemoryTidy: %v", err)
|
||||
}
|
||||
if !res.Written || res.Existed || res.Warn != nil {
|
||||
t.Fatalf("结果 = %+v, want Written 且非 Existed 且无 Warn", res)
|
||||
}
|
||||
assertNoTempLeft(t, path)
|
||||
|
||||
// 十键齐全(含新增的四项频率键)——「别只播老六键」的断言就在这里。
|
||||
keys := decodeSeed(t, path)
|
||||
for _, key := range memoryTidySeedKeys {
|
||||
if _, ok := keys[key]; !ok {
|
||||
t.Fatalf("播种文件缺键 %q:%s", key, readRaw(t, path))
|
||||
}
|
||||
}
|
||||
if len(keys) != len(memoryTidySeedKeys) {
|
||||
t.Fatalf("播种文件的键数 = %d, want %d:%s", len(keys), len(memoryTidySeedKeys), readRaw(t, path))
|
||||
}
|
||||
|
||||
// 回读走的是**运行期同一条**读取路径(LoadMemoryTidyTuning),因此这里同时钉住
|
||||
// 「写下去的东西读回来就是那份快照」。
|
||||
got, err := LoadMemoryTidyTuning(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadMemoryTidyTuning: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, seed) {
|
||||
t.Fatalf("回读 = %+v, want %+v(播种内容必须逐键等于调用方给的生效值)", got, seed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_NeverTouchesExistingFile 钉住第一条铁律:文件在 ⇒ 一个字节都不动。
|
||||
// 覆盖四种盘上形态(面板值 / 空对象 / null / 手写带未知键),四种都必须原样保留 ——
|
||||
// 尤其 `null`:它解得出零值(语义上等于没配),但它是「盘上有一个文件」这个事实本身。
|
||||
func TestSeedMemoryTidy_NeverTouchesExistingFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
bodies := []string{
|
||||
`{"autoApply": false}`,
|
||||
`{}`,
|
||||
`null`,
|
||||
"{\n \"intervalSec\": 42,\n \"unknownKey\": \"keep me\"\n}\n",
|
||||
}
|
||||
for _, body := range bodies {
|
||||
path := writeMemoryTidyFile(t, body)
|
||||
before := readRaw(t, path)
|
||||
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("%q: SeedMemoryTidy: %v", body, err)
|
||||
}
|
||||
if res.Written || !res.Existed || res.Warn != nil {
|
||||
t.Fatalf("%q: 结果 = %+v, want 跳过(Existed 且非 Written 且无 Warn)", body, res)
|
||||
}
|
||||
if after := readRaw(t, path); after != before {
|
||||
t.Fatalf("%q: 已存在的文件被改了字节:\n before=%s\n after =%s", body, before, after)
|
||||
}
|
||||
assertNoTempLeft(t, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_BrokenFileIsNotRepaired 钉住第二条纪律:损坏 ⇒ WARN + 按 `.env`
|
||||
// 跑 + **一个字都不写盘**。损坏的文件可以人工救回,程序盖掉它就丢了那份人工意图。
|
||||
func TestSeedMemoryTidy_BrokenFileIsNotRepaired(t *testing.T) {
|
||||
t.Parallel()
|
||||
bodies := []string{
|
||||
`{"autoApply": "yes"}`,
|
||||
`{"intervalSec": "3600"}`,
|
||||
`not json at all`,
|
||||
`[1,2,3]`,
|
||||
}
|
||||
for _, body := range bodies {
|
||||
path := writeMemoryTidyFile(t, body)
|
||||
before := readRaw(t, path)
|
||||
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
|
||||
if err != nil {
|
||||
// 坏文件不是「该写却没写成」:它压根不该被写,因此不是 error。
|
||||
t.Fatalf("%q: 坏文件不该让播种返回错误(它只是不写盘):%v", body, err)
|
||||
}
|
||||
if res.Written || !res.Existed {
|
||||
t.Fatalf("%q: 结果 = %+v, want 既不写也不当新文件", body, res)
|
||||
}
|
||||
if !errors.Is(res.Warn, ErrInvalidMemoryTidy) {
|
||||
t.Fatalf("%q: Warn = %v, want ErrInvalidMemoryTidy(分类错误必须与读取侧同一个)", body, res.Warn)
|
||||
}
|
||||
if after := readRaw(t, path); after != before {
|
||||
t.Fatalf("%q: 损坏的文件被覆盖/「修好」了:\n before=%s\n after =%s", body, before, after)
|
||||
}
|
||||
assertNoTempLeft(t, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_IsIdempotent 钉住幂等:连跑两次,只有第一次写盘,第二次连字节
|
||||
// 都不改(这是「连续启动多次只写一次」的可验证版本)。
|
||||
func TestSeedMemoryTidy_IsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := seedPath(t)
|
||||
first, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
|
||||
if err != nil || !first.Written {
|
||||
t.Fatalf("第一次: Written=%v err=%v, want 落盘", first.Written, err)
|
||||
}
|
||||
afterFirst := readRaw(t, path)
|
||||
second, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("第二次: %v", err)
|
||||
}
|
||||
if second.Written || !second.Existed {
|
||||
t.Fatalf("第二次 = %+v, want 跳过(文件已在)", second)
|
||||
}
|
||||
if afterSecond := readRaw(t, path); afterSecond != afterFirst {
|
||||
t.Fatalf("第二次改动了文件:\n first=%s\n second=%s", afterFirst, afterSecond)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_SeedsBlankFile 钉住「全空白」的处置:它与「缺失」同义是既有口径
|
||||
// (LoadMemoryTidyTuning 把两者都读成「还没配过」),因此也播种 —— 那一刻的生效值
|
||||
// 一个字都不变。
|
||||
func TestSeedMemoryTidy_SeedsBlankFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := writeMemoryTidyFile(t, " \n\t")
|
||||
res, err := SeedMemoryTidy(path, completeSeedTuning(), discardLog())
|
||||
if err != nil {
|
||||
t.Fatalf("SeedMemoryTidy: %v", err)
|
||||
}
|
||||
if !res.Written || res.Existed {
|
||||
t.Fatalf("结果 = %+v, want 按「还没配过」播种", res)
|
||||
}
|
||||
if len(decodeSeed(t, path)) != len(memoryTidySeedKeys) {
|
||||
t.Fatalf("空白文件被播种后应当十键齐全:%s", readRaw(t, path))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_RefusesIncompleteSeed 钉住种子的完整性契约:残缺的快照
|
||||
// (缺指针 / 非正周期 / 钟点越界)宁可 WARN + 不落盘,也不写出一份自己读回都要
|
||||
// WARN、或满是 null 的文件。
|
||||
func TestSeedMemoryTidy_RefusesIncompleteSeed(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
seed MemoryTidyTuning
|
||||
}{
|
||||
{"缺 autoApply", func() MemoryTidyTuning { s := completeSeedTuning(); s.AutoApply = nil; return s }()},
|
||||
{"缺 intervalSec", func() MemoryTidyTuning { s := completeSeedTuning(); s.IntervalSec = nil; return s }()},
|
||||
{"缺 quietStartHour", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietStartHour = nil; return s }()},
|
||||
{"intervalSec 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.IntervalSec = intSeedPtr(0); return s }()},
|
||||
{"quietIntervalSec 为负", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietIntervalSec = intSeedPtr(-1); return s }()},
|
||||
{"autoApplyPerRound 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.AutoApplyPerRound = 0; return s }()},
|
||||
{"t1PerWindow 为 0", func() MemoryTidyTuning { s := completeSeedTuning(); s.T1PerWindow = 0; return s }()},
|
||||
{"t1WindowSec 为负", func() MemoryTidyTuning { s := completeSeedTuning(); s.T1WindowSec = -60; return s }()},
|
||||
{"quietStartHour 越界", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietStartHour = intSeedPtr(24); return s }()},
|
||||
{"quietEndHour 越界", func() MemoryTidyTuning { s := completeSeedTuning(); s.QuietEndHour = intSeedPtr(-1); return s }()},
|
||||
{"完全零值", MemoryTidyTuning{}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
path := seedPath(t)
|
||||
if _, err := SeedMemoryTidy(path, tc.seed, discardLog()); err == nil {
|
||||
t.Fatalf("%s: err = nil, want 拒绝残缺种子", tc.name)
|
||||
}
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("%s: 残缺种子不得落盘:%s", tc.name, readRaw(t, path))
|
||||
}
|
||||
assertNoTempLeft(t, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedMemoryTidy_LogsPathAndReasonWithoutValues 钉住「可观测」:写盘与跳过都有
|
||||
// 日志(带路径与原因、带 source),且**不打任何配置值**(本仓配置日志的既有口径)。
|
||||
func TestSeedMemoryTidy_LogsPathAndReasonWithoutValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// ① 播种:Info 日志带路径、原因、source 与键名,但没有任何值。
|
||||
// 日志级别用默认的 Info(不是 Debug):主人要在**默认日志级别**下就能看到这件事
|
||||
// 发生了 —— 可观测是这条口径的一部分,不是「把级别调高才看得见」。
|
||||
var buf bytes.Buffer
|
||||
log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
path := seedPath(t)
|
||||
seed := completeSeedTuning()
|
||||
seed.IntervalSec = intSeedPtr(4242) // 一眼可辨的值:它**不许**出现在日志里
|
||||
if _, err := SeedMemoryTidy(path, seed, log); err != nil {
|
||||
t.Fatalf("SeedMemoryTidy: %v", err)
|
||||
}
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, memoryTidyFileName) {
|
||||
t.Fatalf("播种日志没带路径:%s", out)
|
||||
}
|
||||
if !strings.Contains(out, memoryTidySeedReasonMissing) {
|
||||
t.Fatalf("播种日志没带原因:%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "source=config/runtimeconfig") {
|
||||
t.Fatalf("播种日志没标来源(AGENTS §10 禁止无来源裸日志):%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "已按当时生效值播种落盘") {
|
||||
t.Fatalf("播种日志没说明做了什么:%s", out)
|
||||
}
|
||||
for _, value := range []string{"4242", "10800", "1800"} {
|
||||
if strings.Contains(out, value) {
|
||||
t.Fatalf("日志里出现了配置值 %s(配置日志只许打键名):%s", value, out)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 再次调用(文件已在):跳过也要有日志(**默认 Info 级别**可见,含路径与原因),
|
||||
// 且同样不打值。
|
||||
buf.Reset()
|
||||
if _, err := SeedMemoryTidy(path, seed, log); err != nil {
|
||||
t.Fatalf("第二次 SeedMemoryTidy: %v", err)
|
||||
}
|
||||
skipped := buf.String()
|
||||
if !strings.Contains(skipped, "跳过播种") || !strings.Contains(skipped, memoryTidyFileName) {
|
||||
t.Fatalf("跳过播种的日志缺路径或说明:%s", skipped)
|
||||
}
|
||||
if !strings.Contains(skipped, "绝不覆盖") {
|
||||
t.Fatalf("跳过播种的日志没说明理由(绝不覆盖):%s", skipped)
|
||||
}
|
||||
if !strings.Contains(skipped, memoryTidySeedReasonExists) {
|
||||
t.Fatalf("跳过播种的日志没带 reason 字段:%s", skipped)
|
||||
}
|
||||
for _, value := range []string{"4242", "10800", "1800"} {
|
||||
if strings.Contains(skipped, value) {
|
||||
t.Fatalf("跳过日志里出现了配置值 %s:%s", value, skipped)
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 损坏:WARN 日志带路径与分类错误(主人据此知道「这份配置没生效、也没被改」)。
|
||||
buf.Reset()
|
||||
broken := writeMemoryTidyFile(t, `{"autoApply": "yes"}`)
|
||||
if _, err := SeedMemoryTidy(broken, seed, log); err != nil {
|
||||
t.Fatalf("坏文件的 SeedMemoryTidy: %v", err)
|
||||
}
|
||||
warned := buf.String()
|
||||
if !strings.Contains(warned, "level=WARN") || !strings.Contains(warned, "无法解析") {
|
||||
t.Fatalf("损坏文件必须留一条 WARN:%s", warned)
|
||||
}
|
||||
if !strings.Contains(warned, memoryTidyFileName) {
|
||||
t.Fatalf("WARN 没带路径:%s", warned)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMemoryTidySeedKeysMatchStruct 是**防漂移**断言:播种日志里列的键名与
|
||||
// MemoryTidyTuning 的 json tag 必须一一对应。加了新键却忘了更新 memoryTidySeedKeys,
|
||||
// 用例就会红 —— 否则「播了哪些键」的日志会安静地漏报。
|
||||
func TestMemoryTidySeedKeysMatchStruct(t *testing.T) {
|
||||
t.Parallel()
|
||||
typ := reflect.TypeOf(MemoryTidyTuning{})
|
||||
var tags []string
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
tag, ok := typ.Field(i).Tag.Lookup("json")
|
||||
if !ok || tag == "" {
|
||||
t.Fatalf("字段 %s 没有 json tag", typ.Field(i).Name)
|
||||
}
|
||||
tags = append(tags, strings.Split(tag, ",")[0])
|
||||
}
|
||||
want := append([]string(nil), memoryTidySeedKeys...)
|
||||
sort.Strings(tags)
|
||||
sort.Strings(want)
|
||||
if !reflect.DeepEqual(tags, want) {
|
||||
t.Fatalf("结构体的键 %v 与 memoryTidySeedKeys %v 不一致(加了新键就要同步那张表)", tags, want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user