tempTtlSec 落地后,`memory-tidy.json` 的播种契约就是**十一键**,而三处文件的注释仍 写「十键」。这些是**过时的陈述**(不是风格问题):下一位读者会从「快照契约:十键非 nil」里读出一个错误的口径,而 `memoryTidySeedComplete` 实际校验的正是十一键。 改的只是措辞,一行逻辑没动: - internal/config/runtimeconfig/memorytidy_seed.go:25,160 - internal/config/runtimeconfig/memorytidy_seed_test.go:19,27,62,82,97,214 - internal/runtime/memory/tidy_snapshot_test.go:14,27,159 (`cmd/cyrene/wire_memory_tidy.go:65` 还有同类的一处「这十键」,该文件属 M3 的 scope,未动 —— 塔已转 M3。) 验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿。
232 lines
12 KiB
Go
232 lines
12 KiB
Go
// 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", "tempTtlSec",
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
// tempTtlSec 同样要求非 nil:它是 `*int` + `omitempty`,**nil 会让该键从播种
|
||
// 文件里消失**,于是「播种文件与当时的生效值逐字一致」这句话就不再成立 ——
|
||
// 文件少一个键、读回来却是代码默认值,主人打开文件无从判断到底配没配。
|
||
// 快照侧(memory.TidyTuningSnapshot)恒填它,因此这条不会拦住正常路径。
|
||
if seed.TempTtlSec == nil {
|
||
missing = append(missing, "tempTtlSec")
|
||
}
|
||
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)
|
||
}
|
||
// tempTtlSec(临时记忆 TTL,design/03 §9.6)的**非 nil 要求**见上面 missing
|
||
// 那一段(理由:nil 会让键从文件里消失,而播种要「与生效值逐字一致」)。
|
||
// 这里再加一道值域:≤0 的 TTL 会让每条临时记忆**落库即过期**(与「不过期」
|
||
// 恰好相反),播出去等于播了一份自己读回都要 WARN 的配置。
|
||
if *seed.TempTtlSec <= 0 {
|
||
return fmt.Errorf("runtimeconfig: memory tidy seed tempTtlSec = %d (want > 0)", *seed.TempTtlSec)
|
||
}
|
||
return nil
|
||
}
|