feat(memory): 启动时自动播种面板配置文件(缺失才写、绝不覆盖)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

主人 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:
AskaEth committed 2026-10-07 20:38:37 +08:00
1 parent d680390300
commit 23b6e3fe1e
8 files changed
+1156 -6

No files matched your search

+31 -1
View File
@@ -23,7 +23,10 @@ import (
// (intervalSec / quietIntervalSec / quietStartHour / quietEndHour)。它是
// `.env` 冻结(design/13 §2.1)之后新旋钮的唯一去处,**零新 env 键**;面板优先、
// `.env` 兜底,键缺位或值不可用一律回落下一档并记 WARN(坏配置绝不能让自动档
// 变成「无限制」,也不能让整理频率变成忙轮询)。
// 变成「无限制」,也不能让整理频率变成忙轮询);
// - **启动播种**(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件**要做**」):
// 那个文件不存在时由后端自己写一份「当前生效值」进去(seedMemoryTidyPanel),
// 使「面板优先」从第一次启动起就成立,而不是靠 `.env` 一直兜着。
//
// 刻意**不做**的一件事(design §4.1 F):
// - 不把 sess-memorytidy 加进 flowPub 的图源白名单(默认关:整理是系统任务,
@@ -31,6 +34,12 @@ import (
func (a *app) wireMemoryTidy() error {
cfg := memory.TidyConfigFromConfig(a.cfg.MemoryTidy)
a.applyMemoryTidyTuning(&cfg)
// 启动播种(主人 2026-10-07:「后端**自动创建**独立的面板配置文件**要做**」):
// 文件不存在/空白 ⇒ 把**此刻的生效值**写成面板的一等来源,此后 `.env` 只兜底。
// 放在 applyMemoryTidyTuning **之后**是刻意的:那一刻的 cfg 就是
// 「面板 > `.env` > 代码默认」的结果(面板缺失时即 `.env` + 默认),播它 ⇒ 行为
// 零变化。文件已存在(含损坏)⇒ 一个字节都不动(幂等,见 runtimeconfig.SeedMemoryTidy)。
a.seedMemoryTidyPanel(cfg)
svc, err := memory.NewTidyService(cfg, memory.TidyDeps{
Store: a.memStore,
Embedder: a.embedder,
@@ -44,6 +53,27 @@ func (a *app) wireMemoryTidy() error {
return nil
}
// seedMemoryTidyPanel 在 `$DATA_DIR/config/memory-tidy.json` 缺失/空白时播种一份
// 「当前生效值」快照(`memory.TidyTuningSnapshot`),使面板配置从第一次启动起就自足,
// 而不是每次都靠 `.env` 兜。失败只记日志、**绝不阻断启动**:运行期仍按 `.env` +
// 代码默认跑,行为与播种前逐字相同(与 materializeModels 同一口径)。
//
// 闸与 T1 的四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec)在 `.env` 里
// **没有出处**(`.env` 已冻结,它们只由这个面板文件与代码默认决定,见
// wireMemoryReview 的三个 helper),而播种只发生在**面板文件不存在**时 —— 那正是
// 「零值 + normalizeReviewConfig」这一条路径,因此这里给零值 ReviewConfig 就是
// **当时**的生效值,不是猜的。审查开关本身(MEMORY_REVIEW_ENABLED)不在这十键里,
// 因此不必为它去读一次 env。
func (a *app) seedMemoryTidyPanel(tidy memory.TidyConfig) {
path := runtimeconfig.DefaultMemoryTidyPath(resolveDataDir(a.cfg))
log := logger.WithSource(a.log, "memory/tidy")
// 播种函数自己会把「跳过 / 无法解析」记成带路径的日志(含 WARN),这里只报
// 「该写却没写成」那一种 —— 不重复记同一条 WARN。
if _, err := runtimeconfig.SeedMemoryTidy(path, memory.TidyTuningSnapshot(tidy, memory.ReviewConfig{}), log); err != nil {
log.Warn("memory-tidy.json 播种失败(面板配置仍缺位,.env 继续兜底)", "path", path, "error", err)
}
}
// applyMemoryTidyTuning 把 `$DATA_DIR/config/memory-tidy.json` 叠到 env 侧的
// TidyConfig 上(见 runtimeconfig.MemoryTidyTuning 的零值口径):
// - 文件里**没写** autoApply ⇒ 保持代码默认(开);
+267
View File
@@ -0,0 +1,267 @@
package main
import (
"context"
"os"
"path/filepath"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定**启动播种在组合根上的接线**(wire_memory_tidy.go 的 seedMemoryTidyPanel;
// 主人 2026-10-07:「`.env` 只做兜底用 —— 后端**自动创建**独立的面板配置文件**要做**」):
//
// ① 文件不存在 ⇒ 后端自己写一份「当时生效值」(`.env` 给了的用 `.env`,没给的用代码
// 默认),面板配置自此自足;
// ② 播种之后再走一遍解析,生效配置与没播种时**逐字相同**(播种那一刻行为零变化);
// ③ 幂等:再跑一次不改字节(文件已在 ⇒ 走「绝不覆盖」那条路)。
//
// 用例全部落在临时 DataDir(`t.TempDir()`):生产那份 data/config/memory-tidy.json 是
// 主人手工建的,用例不许依赖它存在与否,也不许碰它。
// panelPath 返回某个 DataDir 下面板配置的落点(与组合根同一套路径推导)。
func panelPath(dataDir string) string {
return runtimeconfig.DefaultMemoryTidyPath(dataDir)
}
// readPanel 读回面板文件原文(幂等断言要比字节,不能只看解出来的键)。
func readPanel(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读回 %s: %v", path, err)
}
return string(raw)
}
// TestWireMemoryTidySeedsPanelOnBoot 是最接近主人场景的一条:直接调**组合根的真实
// 装配函数** `wireMemoryTidy`(它被 wireMemory → newApp 调用,即进程启动那条路),
// 断言这一次启动就**自己把面板配置文件建出来**了;再跑一次(= 第二次启动)字节不变。
//
// 为什么不用「跑一遍真二进制」来验收:那要再起一个 :8080 的同进程服务,与「后端常驻」
// 那条红线冲突(不许为验收停/重启正在跑的后端)。因此这里走的是**同一个函数**、同一套
// 路径推导,只把 DataDir 换成临时目录。
func TestWireMemoryTidySeedsPanelOnBoot(t *testing.T) {
t.Parallel()
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer func() { _ = conn.Close() }()
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
t.Fatalf("migrate: %v", err)
}
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 3600, QuietStartHour: 23, QuietEndHour: 7}
dataDir := t.TempDir()
a := &app{
cfg: &config.Config{DataDir: dataDir, MemoryTidy: env},
memStore: store,
log: tidyTestLog(),
}
if err := a.wireMemoryTidy(); err != nil {
t.Fatalf("wireMemoryTidy: %v", err)
}
path := panelPath(dataDir)
if _, err := os.Stat(path); err != nil {
t.Fatalf("启动装配没有自动创建 %s:%v(这正是主人要求的那条「后端自动创建要做」)", path, err)
}
got, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("回读播种结果: %v", err)
}
if got.IntervalSec == nil || *got.IntervalSec != 3600 ||
got.QuietIntervalSec == nil || *got.QuietIntervalSec != 3600 {
t.Fatalf("两档周期 = %v/%v, want 3600/3600(取 `.env`)", got.IntervalSec, got.QuietIntervalSec)
}
if got.QuietStartHour == nil || *got.QuietStartHour != 23 ||
got.QuietEndHour == nil || *got.QuietEndHour != 7 {
t.Fatalf("安静时段 = %v-%v, want 23-7", got.QuietStartHour, got.QuietEndHour)
}
// 第二次启动(同一个装配函数再跑一次):文件已在 ⇒ 一个字节都不动。
before := readPanel(t, path)
second := &app{
cfg: &config.Config{DataDir: dataDir, MemoryTidy: env},
memStore: store,
log: tidyTestLog(),
}
if err := second.wireMemoryTidy(); err != nil {
t.Fatalf("第二次 wireMemoryTidy: %v", err)
}
if after := readPanel(t, path); after != before {
t.Fatalf("第二次启动改动了面板文件:\n before=%s\n after =%s", before, after)
}
}
// TestSeedMemoryTidyPanel_WritesEffectiveValuesOnBoot 钉住「自动创建」这件事本身:
// 组合根启动时必须自己把面板文件建出来,内容 = 当时生效值(`.env` 档 + 代码默认)。
func TestSeedMemoryTidyPanel_WritesEffectiveValuesOnBoot(t *testing.T) {
t.Parallel()
// `.env` 层:与代码默认**故意不同**,这样「播的是生效值」与「播了一份硬编码默认值」
// 一眼可分。
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 10800, QuietStartHour: 22, QuietEndHour: 6}
dataDir := t.TempDir()
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
path := panelPath(dataDir)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("前置:%s 不该存在", path)
}
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
if _, err := os.Stat(path); err != nil {
t.Fatalf("启动播种没有把面板文件建出来:%v", err)
}
got, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
t.Fatalf("回读播种结果: %v", err)
}
// `.env` 档:三键如实落进快照(quietIntervalSec 没有 env 键 ⇒ 缺省 = intervalSec)。
if got.IntervalSec == nil || *got.IntervalSec != 10800 {
t.Fatalf("intervalSec = %v, want 10800(取 `.env`)", got.IntervalSec)
}
if got.QuietIntervalSec == nil || *got.QuietIntervalSec != 10800 {
t.Fatalf("quietIntervalSec = %v, want 10800(缺省 = intervalSec)", got.QuietIntervalSec)
}
if got.QuietStartHour == nil || *got.QuietStartHour != 22 ||
got.QuietEndHour == nil || *got.QuietEndHour != 6 {
t.Fatalf("安静时段 = %v-%v, want 22-6(取 `.env`)", got.QuietStartHour, got.QuietEndHour)
}
// 代码默认档:老六键必须齐(否则「面板能管住自动档/闸/T1」仍是空话)。
if got.AutoApply == nil || !*got.AutoApply || got.AutoApplyPerRound <= 0 {
t.Fatalf("自动档两键 = %v/%d, want 显式 true 且正数上限", got.AutoApply, got.AutoApplyPerRound)
}
if got.IngestGate == nil || !*got.IngestGate || got.T1Judge == nil || !*got.T1Judge {
t.Fatalf("闸/T1 开关 = %v/%v, want 显式 true", got.IngestGate, got.T1Judge)
}
if got.T1PerWindow <= 0 || got.T1WindowSec <= 0 {
t.Fatalf("T1 限速 = %d/%d, want 正数", got.T1PerWindow, got.T1WindowSec)
}
// 幂等 + 「绝不覆盖」:再跑一次,字节一个不动。
before := readPanel(t, path)
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
if after := readPanel(t, path); after != before {
t.Fatalf("第二次播种改动了面板文件:\n before=%s\n after =%s", before, after)
}
}
// TestSeedMemoryTidyPanel_KeepsRuntimeBehaviorIdentical 钉住「播种那一刻行为零变化」:
// 播种前后各按组合根的次序解析一次,得到的生效配置必须逐字相同 —— 否则播种就成了
// 一次没人察觉的行为变更,而不是「把现状写进文件」。
func TestSeedMemoryTidyPanel_KeepsRuntimeBehaviorIdentical(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 7200, QuietStartHour: 1, QuietEndHour: 5}
dataDir := t.TempDir()
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
// 比对的是**归一化之后**的生效配置:TidyConfig 是归一化前的中间形态,面板写不写
// quietIntervalSec / autoApplyPerRound 会让它在 0 与「等于生效值」之间变化,语义却
// 相同 —— 因此必须走 normalizeTidyConfig 这一层(memory.NewTidier 的既有入口)才能
// 说「行为逐字未变」。
ctx := context.Background()
conn, err := db.Open(ctx, ":memory:", tidyTestLog())
if err != nil {
t.Fatalf("open db: %v", err)
}
defer func() { _ = conn.Close() }()
if err := db.MigrateUp(ctx, conn, tidyTestLog()); err != nil {
t.Fatalf("migrate: %v", err)
}
store, err := memory.NewStore(ctx, conn, tidyTestLog())
if err != nil {
t.Fatalf("memory store: %v", err)
}
effective := func(t *testing.T, cfg memory.TidyConfig) memory.TidyConfig {
t.Helper()
tidier, err := memory.NewTidier(cfg, memory.TidyDeps{Store: store, Log: tidyTestLog()})
if err != nil {
t.Fatalf("NewTidier: %v", err)
}
return tidier.Config() // 已套默认值
}
// ① 播种前:面板文件不存在 ⇒ 只有 `.env` + 代码默认这一档。
before := memory.TidyConfigFromConfig(env)
a.applyMemoryTidyTuning(&before)
before = effective(t, before)
// ② 启动播种。
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
// ③ 播种后:面板文件已在,同样的 env 再解析一次。
after := memory.TidyConfigFromConfig(env)
a.applyMemoryTidyTuning(&after)
after = effective(t, after)
if before != after {
t.Fatalf("播种改变了生效配置:\n before=%+v\n after =%+v", before, after)
}
// 两档周期(intervalAt 的真判据)也要如实等价:安静时段窗口内用 quietInterval。
if after.QuietInterval != after.Interval || after.Interval != time.Duration(env.IntervalSec)*time.Second {
t.Fatalf("两档周期 = %s/%s, want 都等于 `.env` 的 %s",
after.Interval, after.QuietInterval, time.Duration(env.IntervalSec)*time.Second)
}
}
// TestSeedMemoryTidyPanel_PanelTakesOverAfterSeeding 是主人口径的终点:
// 「后端自动创建独立的面板配置文件之后就走面板配置文件」—— 播种之后,改那个文件
// 必须真的改变生效值(否则「自动创建」只是造了个没人读的文件)。
func TestSeedMemoryTidyPanel_PanelTakesOverAfterSeeding(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
dataDir := t.TempDir()
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
path := panelPath(dataDir)
if err := os.WriteFile(path, []byte(`{"intervalSec": 3600}`), 0o600); err != nil {
t.Fatalf("改写面板文件: %v", err)
}
cfg := memory.TidyConfigFromConfig(env)
a.applyMemoryTidyTuning(&cfg)
if cfg.IntervalSec != 3600 {
t.Fatalf("intervalSec = %d, want 3600(面板优先,`.env` 只兜底)", cfg.IntervalSec)
}
// 面板没写的键仍由 `.env` 兜底(面板只管它写了的那几个键)。
if cfg.QuietStartHour != 23 || cfg.QuietEndHour != 7 {
t.Fatalf("安静时段 = %02d:00-%02d:00, want `.env` 的 23:00-07:00", cfg.QuietStartHour, cfg.QuietEndHour)
}
}
// TestSeedMemoryTidyPanel_LeavesExistingFileAlone 钉住组合根这一侧的「绝不覆盖」:
// 盘上已有一份主人手写的配置(哪怕缺键、哪怕带未知字段)⇒ 启动播种一个字节都不动。
func TestSeedMemoryTidyPanel_LeavesExistingFileAlone(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
dataDir := t.TempDir()
dir := filepath.Join(dataDir, "config")
if err := os.MkdirAll(dir, 0o700); err != nil {
t.Fatalf("mkdir: %v", err)
}
body := "{\n \"autoApply\": false,\n \"手工写的未知键\": 1\n}\n"
path := panelPath(dataDir)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("写手写的配置: %v", err)
}
a := &app{cfg: &config.Config{DataDir: dataDir, MemoryTidy: env}, log: tidyTestLog()}
a.seedMemoryTidyPanel(memory.TidyConfigFromConfig(env))
if got := readPanel(t, path); got != body {
t.Fatalf("已存在的面板文件被改了:\n want=%s\n got =%s", body, got)
}
}
+5
View File
@@ -57,6 +57,11 @@ func TestMemoryTidyIsWired(t *testing.T) {
// 来,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。少了这一句,
// 那个文件就成了「写了没人读」的摆设,而自动档仍然开着 —— 静默。
"runtimeconfig.LoadMemoryTidyTuning",
// 启动播种(2026-10-07 追加,主人「后端**自动创建**独立的面板配置文件
// **要做**」):少了这两句,那个文件在生产里**根本不存在** ⇒「面板优先」
// 永远是空话,生效的仍是 `.env` 兜底那一档 —— 而不会有任何用例变红。
"a.seedMemoryTidyPanel(cfg)",
"runtimeconfig.SeedMemoryTidy(",
// 依赖面里**没有**任何会话/历史读数 —— 这是「绝不碰主会话上下文」
// 的结构性保证,而不是靠自觉。
"Audit: a.auditMemoryTidy",
+18 -5
View File
@@ -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)
}
}
+85
View File
@@ -0,0 +1,85 @@
package memory
import (
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是「启动播种」的**值来源**:把 `$DATA_DIR/config/memory-tidy.json` 十键的
// **当前生效值**拍成一份完整快照,交给 runtimeconfig.SeedMemoryTidy 落盘。
//
// 它回应主人 2026-10-07 的两条口径:
//
// - 「**`.env` 只做兜底用** —— 后端**自动创建**独立的面板配置文件之后就走面板
// 配置文件,这些键优先从面板配置上取」⇒ 播种的时机与「此后以面板为准」;
// - 「后端**自动创建**独立的面板配置文件**要做**」⇒ 这份快照就是「自动创建」的
// 内容,**零新 env 键**(`.env` 已冻结,design/13 §2.1)。
//
// 为什么放在 memory 而不是 runtimeconfig:runtimeconfig 只管**文件形状与读写**,
// 「缺位/坏值怎么回落、默认值是多少」属于 TidyConfig / ReviewConfig 的归一化语义
// (与 normalizeTidyConfig / normalizeReviewConfig 同一层)。
//
// 为什么快照取**归一化之后**的值:主人要的是「播种那一刻行为零变化」——把当时真正
// 生效的值写成文件,读回来叠上去之后(面板这一档等于生效值本身)结果逐字相同。
// 若快照取的是「未归一化的中间值」(比如四键还是 0),写下去的就是一份**比现状更空**
// 的配置,只是侥幸等价;取归一化后的值,等价性才是结构性的(见
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig)。
//
// 为什么只有一处实现:十键的默认值在 memory 侧各只有一处定义
// (tidy_config.go / preinsert_config.go 的 default* 常量),快照直接复用那两个
// normalize,**不另写一份默认值** —— 否则播种写下去的就是第二份「差不多」的默认值,
// 会与运行期悄悄分叉。
//
// 十键与它们的生效来源:
//
// 键 生效来源(无面板文件时) 快照值
// autoApply DisableAutoApply 的反向(代码默认开) true
// autoApplyPerRound defaultTidyAutoApplyPerRound 50
// ingestGate DisableIngestGate 的反向(默认开) true
// t1Judge DisableT1 的反向(默认开) true
// t1PerWindow defaultT1PerWindow 120
// t1WindowSec defaultT1Window 3600
// intervalSec TidyConfig.IntervalSec(env 或 86400) 归一化值
// quietIntervalSec 缺省 = intervalSec 归一化值
// quietStartHour TidyConfig.QuietStartHour(env 或 23) 归一化值
// quietEndHour TidyConfig.QuietEndHour(env 或 7) 归一化值
// TidyTuningSnapshot 拍下十键的**当前生效值**,返回一份可直接落盘的面板快照。
//
// 入参是**已经叠过面板层的**两个配置(组合根的次序:TidyConfigFromConfig →
// ApplyTidyScheduleTuning → 本函数;审查侧同理),本函数只做最后一步归一化并翻成
// 文件形状 —— 它不读盘、不读 env、不认识面板文件:
//
// - tidy 侧四键(intervalSec / quietIntervalSec / quietStartHour / quietEndHour)
// 与自动档两键(autoApply / autoApplyPerRound);
// - review 侧四键(ingestGate / t1Judge / t1PerWindow / t1WindowSec):
// 这四项在 `.env` 里**没有出处**(`.env` 已冻结),只由面板文件与代码默认决定,
// 而播种只发生在**面板文件不存在**时 —— 那正是「零值 + normalizeReviewConfig」
// 这一条路径,与运行期(wireMemoryReview 的三个 helper)逐字一致。
//
// 两个开关是**反向**字段(DisableX),快照要把它们翻回正向的 JSON 键:文件里的
// `"autoApply": true` 才是「没配时到底开不开」的如实陈述。
func TidyTuningSnapshot(tidy TidyConfig, review ReviewConfig) runtimeconfig.MemoryTidyTuning {
tidy = normalizeTidyConfig(tidy)
review = normalizeReviewConfig(review)
autoApply := !tidy.DisableAutoApply
ingestGate := !review.DisableIngestGate
t1Judge := !review.DisableT1
intervalSec := tidy.IntervalSec
quietIntervalSec := tidy.QuietIntervalSec
quietStartHour := tidy.QuietStartHour
quietEndHour := tidy.QuietEndHour
return runtimeconfig.MemoryTidyTuning{
AutoApply: &autoApply,
AutoApplyPerRound: tidy.AutoApplyPerRound,
IngestGate: &ingestGate,
T1Judge: &t1Judge,
T1PerWindow: review.T1PerWindow,
T1WindowSec: int(review.T1Window.Seconds()),
IntervalSec: &intervalSec,
QuietIntervalSec: &quietIntervalSec,
QuietStartHour: &quietStartHour,
QuietEndHour: &quietEndHour,
}
}
@@ -0,0 +1,196 @@
package memory
import (
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定**启动播种的值来源**(tidy_snapshot.go;主人 2026-10-07:「`.env` 只做
// 兜底用 —— 后端**自动创建**独立的面板配置文件**要做**」):
//
// ① 快照十键齐全、值可用(否则 runtimeconfig.SeedMemoryTidy 会拒绝落盘);
// ② 值取「**当时解析出来的生效值**」—— `.env` 给了就用 `.env`,没给就用代码默认
// (默认值来自 normalizeTidyConfig / normalizeReviewConfig,不另写一份);
// ③ 最关键的一条:把快照当成面板值叠回去,**归一化结果与「没有面板文件」逐字相同**
// —— 这就是「播种那一刻行为零变化,之后改文件才生效」。
// snapBoolPtr / snapIntPtr 造「快照里显式写了这个键」的指针。
// (不复用 tidy_tuning_test.go 的 intPtr:那是给**面板**用例造的,两者语义不同,
// 分开命名便于读。)
func snapBoolPtr(v bool) *bool { return &v }
func snapIntPtr(v int) *int { return &v }
// TestTidyTuningSnapshot_CodeDefaults 钉住「什么都没配」时的十键 = 归一化的代码默认值。
// 期望值直接取自默认值常量(本包唯一那处定义),因此这条用例证明的是**接线**:
// 快照确实走了 normalizeTidyConfig / normalizeReviewConfig,而不是另写一份默认值。
func TestTidyTuningSnapshot_CodeDefaults(t *testing.T) {
t.Parallel()
snap := TidyTuningSnapshot(TidyConfig{}, ReviewConfig{})
if snap.AutoApply == nil || !*snap.AutoApply {
t.Fatalf("autoApply = %v, want 显式的 true(代码默认开)", snap.AutoApply)
}
if snap.IngestGate == nil || !*snap.IngestGate {
t.Fatalf("ingestGate = %v, want 显式的 true(代码默认开)", snap.IngestGate)
}
if snap.T1Judge == nil || !*snap.T1Judge {
t.Fatalf("t1Judge = %v, want 显式的 true(代码默认开)", snap.T1Judge)
}
if snap.AutoApplyPerRound != defaultTidyAutoApplyPerRound {
t.Fatalf("autoApplyPerRound = %d, want %d", snap.AutoApplyPerRound, defaultTidyAutoApplyPerRound)
}
if snap.T1PerWindow != defaultT1PerWindow || snap.T1WindowSec != int(defaultT1Window.Seconds()) {
t.Fatalf("T1 限速 = %d/%d, want %d/%d", snap.T1PerWindow, snap.T1WindowSec,
defaultT1PerWindow, int(defaultT1Window.Seconds()))
}
if snap.IntervalSec == nil || *snap.IntervalSec != defaultTidyIntervalSec {
t.Fatalf("intervalSec = %v, want %d", snap.IntervalSec, defaultTidyIntervalSec)
}
// quietIntervalSec 缺省 = intervalSec(normalizeTidyConfig 的既有口径:不因时段换档)。
if snap.QuietIntervalSec == nil || *snap.QuietIntervalSec != defaultTidyIntervalSec {
t.Fatalf("quietIntervalSec = %v, want = intervalSec (%d)", snap.QuietIntervalSec, defaultTidyIntervalSec)
}
if snap.QuietStartHour == nil || *snap.QuietStartHour != defaultTidyQuietStartHour ||
snap.QuietEndHour == nil || *snap.QuietEndHour != defaultTidyQuietEndHour {
t.Fatalf("安静时段 = %v-%v, want %d-%d", snap.QuietStartHour, snap.QuietEndHour,
defaultTidyQuietStartHour, defaultTidyQuietEndHour)
}
}
// TestTidyTuningSnapshot_TakesEnvLayer 钉住「`.env` 给的就用 `.env`」:四键的 env 档
// (MEMORY_TIDY_INTERVAL_SEC / MEMORY_TIDY_QUIET_*_HOUR)必须原样进快照,否则播种写
// 下去的就是代码默认值,等于把主人配的 `.env` 静默抹平。
func TestTidyTuningSnapshot_TakesEnvLayer(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 22, QuietEndHour: 6}
snap := TidyTuningSnapshot(TidyConfigFromConfig(env), ReviewConfig{})
if snap.IntervalSec == nil || *snap.IntervalSec != 7200 {
t.Fatalf("intervalSec = %v, want 7200(取 `.env`)", snap.IntervalSec)
}
// env 只给了 intervalSec,quietIntervalSec 没有 env 键 ⇒ 缺省 = intervalSec。
if snap.QuietIntervalSec == nil || *snap.QuietIntervalSec != 7200 {
t.Fatalf("quietIntervalSec = %v, want 7200(缺省 = intervalSec)", snap.QuietIntervalSec)
}
if snap.QuietStartHour == nil || *snap.QuietStartHour != 22 ||
snap.QuietEndHour == nil || *snap.QuietEndHour != 6 {
t.Fatalf("安静时段 = %v-%v, want 22-6(取 `.env`)", snap.QuietStartHour, snap.QuietEndHour)
}
}
// TestTidyTuningSnapshot_KeepsExplicitZeroHour 钉住钟点的零值口径:**0 是合法钟点**
// (0-7 的凌晨窗口)。若快照把「0 起」当成「没配」而写成 23,播种就篡改了主人的窗口。
//
// 注意「两端都是 0」是**另一回事**:那是 normalize 认的「都没配」(回落到 23-07),
// 这与显式写 0-5 的凌晨窗口必须可区分。
func TestTidyTuningSnapshot_KeepsExplicitZeroHour(t *testing.T) {
t.Parallel()
env := config.MemoryTidyConfig{QuietStartHour: 0, QuietEndHour: 5}
snap := TidyTuningSnapshot(TidyConfigFromConfig(env), ReviewConfig{})
if snap.QuietStartHour == nil || *snap.QuietStartHour != 0 || *snap.QuietEndHour != 5 {
t.Fatalf("安静时段 = %v-%v, want 0-5(显式 0 是合法钟点)", snap.QuietStartHour, snap.QuietEndHour)
}
// 两端都是 0 = 「都没配」⇒ 归一成代码默认 23-07(既有口径,快照如实转述)。
both := TidyTuningSnapshot(TidyConfigFromConfig(config.MemoryTidyConfig{}), ReviewConfig{})
if *both.QuietStartHour != defaultTidyQuietStartHour || *both.QuietEndHour != defaultTidyQuietEndHour {
t.Fatalf("两端 0 = 都没配 ⇒ 应归一成 %d-%d,得到 %v-%v",
defaultTidyQuietStartHour, defaultTidyQuietEndHour, *both.QuietStartHour, *both.QuietEndHour)
}
}
// TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig 是**播种的全部意义**所在:
// 把快照当成面板值叠回去之后,整理与审查两侧归一化出来的配置必须与「面板文件不存在」
// 逐字相同 —— 于是播种那一刻行为零变化,之后改文件才生效。
//
// 两侧各按**运行期真实的叠加路径**走:
// - 整理侧 = 组合根那三步(TidyConfigFromConfig → ApplyTidyScheduleTuning →
// normalizeTidyConfig,即 resolveTidySchedule);
// - 审查侧 = wireMemoryReview 的写法(开关取 IngestGateOff/T1JudgeOff,数值取
// t1PerWindow/t1WindowSec,再交给 normalizeReviewConfig)。
func TestTidyTuningSnapshot_PanelRoundTripKeepsEffectiveConfig(t *testing.T) {
t.Parallel()
envs := []struct {
name string
env config.MemoryTidyConfig
}{
{"`.env` 什么都没配", config.MemoryTidyConfig{}},
{"`.env` 配了频率与窗口", config.MemoryTidyConfig{IntervalSec: 7200, QuietStartHour: 1, QuietEndHour: 5}},
{"`.env` 的窗口从 0 点起(凌晨档)", config.MemoryTidyConfig{IntervalSec: 10800, QuietStartHour: 0, QuietEndHour: 6}},
{"`.env` 把窗口关掉(两端相等且非 0)", config.MemoryTidyConfig{IntervalSec: 3600, QuietStartHour: 5, QuietEndHour: 5}},
{"`.env` 只配了周期", config.MemoryTidyConfig{IntervalSec: 1800}},
}
for _, tc := range envs {
log, _ := captureTidyLog(t)
// ① 没播种前:面板缺失 ⇒ 零值 tuning(= 当前生产里 data/config 下没这个文件的形态)。
before := resolveTidySchedule(tc.env, runtimeconfig.MemoryTidyTuning{}, log)
// ② 快照(这就是要写进面板文件的内容)。
snap := TidyTuningSnapshot(TidyConfigFromConfig(tc.env), ReviewConfig{})
// ③ 播种之后:面板文件就是那份快照,逐键叠回来。
after := resolveTidySchedule(tc.env, snap, log)
if before != after {
t.Fatalf("%s: 播种改变了生效配置:\n before=%+v\n after =%+v", tc.name, before, after)
}
}
// 审查侧同理(闸/T1 四键在 `.env` 里没有出处,只由面板与代码默认决定)。
reviewBefore := normalizeReviewConfig(ReviewConfig{})
snap := TidyTuningSnapshot(TidyConfig{}, ReviewConfig{})
reviewAfter := normalizeReviewConfig(ReviewConfig{
DisableIngestGate: snap.IngestGateOff(),
DisableT1: snap.T1JudgeOff(),
T1PerWindow: snap.T1PerWindow,
T1Window: time.Duration(snap.T1WindowSec) * time.Second,
})
if reviewBefore != reviewAfter {
t.Fatalf("播种改变了审查的生效配置:\n before=%+v\n after =%+v", reviewBefore, reviewAfter)
}
}
// TestTidyTuningSnapshot_IsCompleteAndPositive 钉住快照契约:十键非 nil、数值键为正、
// 钟点在 0..23。runtimeconfig.SeedMemoryTidy 会按这条契约拒收残缺种子,因此这里先
// 在**产生方**把它钉住,免得「播种写不出去」要到启动日志里才发现。
func TestTidyTuningSnapshot_IsCompleteAndPositive(t *testing.T) {
t.Parallel()
snap := TidyTuningSnapshot(TidyConfigFromConfig(config.MemoryTidyConfig{}), ReviewConfig{})
pointers := map[string]*int{
"intervalSec": snap.IntervalSec,
"quietIntervalSec": snap.QuietIntervalSec,
"quietStartHour": snap.QuietStartHour,
"quietEndHour": snap.QuietEndHour,
}
for key, p := range pointers {
if p == nil {
t.Fatalf("%s 是 nil(缺键的种子会被拒收)", key)
}
}
if snap.AutoApply == nil || snap.IngestGate == nil || snap.T1Judge == nil {
t.Fatalf("开关指针不得为 nil:%+v", snap)
}
for key, v := range map[string]int{
"autoApplyPerRound": snap.AutoApplyPerRound,
"t1PerWindow": snap.T1PerWindow,
"t1WindowSec": snap.T1WindowSec,
"intervalSec": *snap.IntervalSec,
"quietIntervalSec": *snap.QuietIntervalSec,
} {
if v <= 0 {
t.Fatalf("%s = %d, want > 0(非正周期会被 runtimeconfig 拒收)", key, v)
}
}
for key, v := range map[string]int{"quietStartHour": *snap.QuietStartHour, "quietEndHour": *snap.QuietEndHour} {
if v < 0 || v > 23 {
t.Fatalf("%s = %d, want 0..23", key, v)
}
}
}