// 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 }