From 23b6e3fe1e85332db1ee95dc4f0108b46a62e94f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 20:38:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(memory):=20=E5=90=AF=E5=8A=A8=E6=97=B6?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=92=AD=E7=A7=8D=E9=9D=A2=E6=9D=BF=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=96=87=E4=BB=B6=EF=BC=88=E7=BC=BA=E5=A4=B1=E6=89=8D?= =?UTF-8?q?=E5=86=99=E3=80=81=E7=BB=9D=E4=B8=8D=E8=A6=86=E7=9B=96=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 2026-10-07:「后端自动创建独立的面板配置文件要做」。 - 启动路径(wireMemoryTidy → applyMemoryTidyTuning 之后)若 $DATA_DIR/config/memory-tidy.json 不存在或全空白 => 写一份「当时生效值」 (十键:既有六键 + 频率四键),此后面板接管 - 已存在(非空)=> 跳过,该分支没有任何写盘语句;损坏 => WARN + 按 .env 跑、 一个字不写盘(不「修好它」) - 幂等(第二次启动必落到跳过分支);原子写(tmp 0600 + rename) - 落盘前完整性闸:缺键 / 非正周期 / 钟点越界 => WARN + 不落盘 - 值来自 TidyTuningSnapshot(先跑两个归一化再翻回正向键)=> 播种那一刻运行 行为零变化,有强断言(归一化后配置逐字相等) - 零新增依赖、零 env 键变更;5 个变异(含「让已存在走写盘」=> 红) --- cmd/cyrene/wire_memory_tidy.go | 32 +- cmd/cyrene/wire_memory_tidy_seed_test.go | 267 ++++++++++++++ cmd/cyrene/wire_memory_tidy_test.go | 5 + internal/config/runtimeconfig/memorytidy.go | 23 +- .../config/runtimeconfig/memorytidy_seed.go | 217 +++++++++++ .../runtimeconfig/memorytidy_seed_test.go | 337 ++++++++++++++++++ internal/runtime/memory/tidy_snapshot.go | 85 +++++ internal/runtime/memory/tidy_snapshot_test.go | 196 ++++++++++ 8 files changed, 1156 insertions(+), 6 deletions(-) create mode 100644 cmd/cyrene/wire_memory_tidy_seed_test.go create mode 100644 internal/config/runtimeconfig/memorytidy_seed.go create mode 100644 internal/config/runtimeconfig/memorytidy_seed_test.go create mode 100644 internal/runtime/memory/tidy_snapshot.go create mode 100644 internal/runtime/memory/tidy_snapshot_test.go diff --git a/cmd/cyrene/wire_memory_tidy.go b/cmd/cyrene/wire_memory_tidy.go index c0dcdaf..280f719 100644 --- a/cmd/cyrene/wire_memory_tidy.go +++ b/cmd/cyrene/wire_memory_tidy.go @@ -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 ⇒ 保持代码默认(开); diff --git a/cmd/cyrene/wire_memory_tidy_seed_test.go b/cmd/cyrene/wire_memory_tidy_seed_test.go new file mode 100644 index 0000000..ae521b4 --- /dev/null +++ b/cmd/cyrene/wire_memory_tidy_seed_test.go @@ -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) + } +} diff --git a/cmd/cyrene/wire_memory_tidy_test.go b/cmd/cyrene/wire_memory_tidy_test.go index c94ac28..2b7d957 100644 --- a/cmd/cyrene/wire_memory_tidy_test.go +++ b/cmd/cyrene/wire_memory_tidy_test.go @@ -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", diff --git a/internal/config/runtimeconfig/memorytidy.go b/internal/config/runtimeconfig/memorytidy.go index 425c592..ed667c0 100644 --- a/internal/config/runtimeconfig/memorytidy.go +++ b/internal/config/runtimeconfig/memorytidy.go @@ -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) diff --git a/internal/config/runtimeconfig/memorytidy_seed.go b/internal/config/runtimeconfig/memorytidy_seed.go new file mode 100644 index 0000000..d770a88 --- /dev/null +++ b/internal/config/runtimeconfig/memorytidy_seed.go @@ -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 +} diff --git a/internal/config/runtimeconfig/memorytidy_seed_test.go b/internal/config/runtimeconfig/memorytidy_seed_test.go new file mode 100644 index 0000000..5a87188 --- /dev/null +++ b/internal/config/runtimeconfig/memorytidy_seed_test.go @@ -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) + } +} diff --git a/internal/runtime/memory/tidy_snapshot.go b/internal/runtime/memory/tidy_snapshot.go new file mode 100644 index 0000000..ef4f8f2 --- /dev/null +++ b/internal/runtime/memory/tidy_snapshot.go @@ -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, + } +} diff --git a/internal/runtime/memory/tidy_snapshot_test.go b/internal/runtime/memory/tidy_snapshot_test.go new file mode 100644 index 0000000..5dd2849 --- /dev/null +++ b/internal/runtime/memory/tidy_snapshot_test.go @@ -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) + } + } +}