feat(memory): 记忆整理调优面板读写(三态 + 热生效 + 面板表单)

主人 2026-10-07 两条口径:「.env 只做兜底用,后端自动创建独立的面板配置文件
之后就走面板配置文件,这些键优先从面板配置上取」+「频率必须调,可以调到 1 小时
或者三小时,或者动态分配(白天短、静默期长)」。

此前 $DATA_DIR/config/memory-tidy.json 只有读路径(装配期读一次 + 启动播种):
主人要改频率只能改文件 + 重启后端。本切片补上「读回来 → 改 → 落盘 → 当场生效」。

后端(读写端点,契约待塔登记 v0.92):
- GET  /api/v1/admin/memories/tidy/tuning  读盘上 7 键值 + 取值规则 specs +
  缺位时 .env 档的进程生效值(与 /admin/config-facts 同源)
- PUT  同路径:三态补丁(键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上一
  个字节都不动)+ null 清除哨兵(删键 ⇒ 回到下一档)+ writeAuth + 审计
  memory.tidy.tuning;值一个都没变时不落盘,文件解不开时拒绝覆盖(409)
- 落盘走 runtimeconfig.PatchMemoryTidyTuning(原子写 temp+rename,与
  SeedMemoryTidy/SaveWebSearch 同族同手法);补丁语义保留下受管的其它键
  (t1Judge/t1PerWindow/autoApplyPerRound 原样保留)
- 热生效:保存后按「面板 > .env > 代码默认」重算并叠到运行期对象;整理档会
  重排心跳(否则新周期要等旧周期那一觉睡完),审查档当场改 ingestGate/tempTtlSec。
  未接线/失败时如实回 hot:false + hotError,绝不把「落盘了」说成「生效了」

前端(Miuix):
- 记忆管理页新增「记忆整理调优」卡:7 键表单(中文标签、单位、留空⇒跟随谁、
  env 档当前值、坏值就地报错并拦住保存、保存后回显从磁盘读回的真值)
- 试算卡的 recallMode 按契约 v0.91 如实渲染:embedding / lexical(已降级)/
  mixed(部分分区降级)/ ""(无候选可比)——空串不再渲染成空标签;并把
  error.code=tidy_recall_unavailable 单列一句(「尝试过但向量源不可用」,与
  「没有候选可比」显式区分)

验证:go test ./internal/{services/gateway,runtime/memory,config/runtimeconfig}
全绿;三态/清除/坏文件拒写/同值不重写/热生效接线各有用例,并逐条做过变异自证;
tsc --noEmit + vite build 通过;两个 esbuild 前端测试 39 + 34 + 41 checks 通过。
This commit is contained in:
AskaEth committed 2026-10-13 21:59:18 +08:00
1 parent 17c41265e8
commit eddf921229
18 files changed
+2604 -5

No files matched your search

@@ -9,6 +9,7 @@ import { Button, Surface, Tag } from "../../components/primitives";
import { MemoryTidyFactsCard } from "./ConfigFactsCards";
import { MemoryTidyDryRunCard } from "./MemoryTidyDryRunCard";
import { MemoryTidyApplyCard } from "./MemoryTidyApplyCard";
import { MemoryTidyTuningCard } from "./MemoryTidyTuningCard";
const scopeTone: Record<string, "ok" | "warn" | "accent" | "neutral"> = {
user: "accent",
@@ -191,6 +192,8 @@ export function MemoriesModule(): React.ReactNode {
<MemoryTidyApplyCard />
<MemoryTidyTuningCard />
<MemoryTidyFactsCard />
<Notice>
@@ -25,6 +25,8 @@ import {
MemoryTidyDryRunBody,
TIDY_DRY_RUN_DISCLAIMER,
TIDY_DRY_RUN_ERROR_HINT,
TIDY_RECALL_UNAVAILABLE,
TIDY_RECALL_UNAVAILABLE_HINT,
type TidyDryRunState,
} from "./MemoryTidyDryRunCard";
@@ -96,7 +98,7 @@ ok(has(doneHtml, "0.950"), "必须显示分数(区间)");
ok(has(doneHtml, "mem-short") && has(doneHtml, "保留 mem-long"), "必须显示「哪条 → 保留哪条」");
ok(has(doneHtml, "作息偏晚"), "必须显示合并后的存活条目原文(diff 预览)");
ok(has(doneHtml, "扫描 736") && has(doneHtml, "用时 42 ms"), "必须显示扫描口径与耗时");
ok(has(doneHtml, "召回来源:embedding"), "必须显示召回来源(lexical 是降级信号)");
ok(has(doneHtml, "召回来源:语义向量(embedding)"), "必须显示召回来源(lexical 是降级信号)");
ok(has(doneHtml, "scope=1"), "必须显示否决分布(它解释「为什么没有更多建议」)");
// ── 截断:必须说明「这一轮看到的比库里少 + 该调哪个键」 ─────────────────────
@@ -124,6 +126,42 @@ ok(!has(errorHtml, "扫描 "), "错误态绝不得渲染观测口径(扫描/
ok(!has(errorHtml, "这一轮没有产生任何候选"), "错误态不得复用空态文案(两者含义不同)");
ok(has(errorHtml, "把失败画成「0 条」"), "错误态反而要解释「为什么不显示 0 条」");
// ── ④ 召回来源(契约 v0.91):四种取值**各有各的话**,空串绝不是空标签 ────────
// 起因是 M6 的诊断(DevDocs/audit/去重失效诊断-2026-10-13.md):旧实现把「全量
// 降级」静默报成 embedding,面板据此判读会把「向量不可用」读成「向量正常」。
// 因此这个字段的**全部价值**就是让人看得出这一轮到底走了哪条路。
const recallCases: Array<[string, string]> = [
["embedding", "语义向量"],
["lexical", "已降级"],
["mixed", "部分分区降级"],
["", "无候选可比"],
["unregistered_mode", "未登记取值"],
];
for (const [mode, want] of recallCases) {
const html = render({ kind: "done", result: { ...RESULT, recallMode: mode } });
ok(has(html, want), `recallMode=${JSON.stringify(mode)} 必须渲染成可读中文(含「${want}」)`);
}
// 空串绝不许渲染成「召回来源:」后面空白(那是本次要修的那个缺陷)。
const noCandidateHtml = render({ kind: "done", result: { ...RESULT, recallMode: "", candidates: [], pairs: 0 } });
ok(has(noCandidateHtml, "无候选可比"), "空串 = 本轮没做召回,必须说出来");
ok(!has(noCandidateHtml, "召回来源:<"), "空串绝不能渲染成空标签(后面什么都没有)");
ok(!has(noCandidateHtml, "召回来源:</"), "空串绝不能渲染成空标签(连标签体都空)");
ok(has(noCandidateHtml, "未做召回"), "空串要解释「这一轮压根没算过相似度」,不能含糊成「—」");
// ── ⑤ 「尝试过召回但向量源不可用」:与上面两种都不同,走**错误**路径 ─────────
// 试算的严格口径下这种失败不回响应体(前端只能从 error.code 认出来),因此这里
// 必须单独给一句说明 —— 否则主人对着一句「试算失败」分不清是没候选还是向量挂了。
const recallDownHtml = render({
kind: "error",
message: "语义召回(embedding)不可用,试算未产出候选(试算不做静默降级)",
code: TIDY_RECALL_UNAVAILABLE,
});
ok(has(recallDownHtml, TIDY_RECALL_UNAVAILABLE_HINT), "向量源不可用时要给专门的说明");
ok(has(recallDownHtml, "不等于"), "必须与「没有候选可比」显式区分");
ok(has(recallDownHtml, "没有降级到词项"), "必须说清它也没有降级到词项余弦");
ok(!has(errorHtml, TIDY_RECALL_UNAVAILABLE_HINT), "别的错误不该套用这条说明(不许乱归因)");
// ── 未跑 / 在跑 ────────────────────────────────────────────────────────────
const idleHtml = render({ kind: "idle" });
ok(has(idleHtml, "试算整理建议"), "未跑时必须给出按钮文案");
@@ -1,5 +1,5 @@
import { useState } from "react";
import { adminMemoryTidyDryRun } from "../../lib/api";
import { ApiError, adminMemoryTidyDryRun } from "../../lib/api";
import type { AdminTidyDryRunCandidate, AdminTidyDryRunResult } from "../../lib/types";
import { Button, Surface, Tag } from "../../components/primitives";
import { Notice } from "../components";
@@ -38,7 +38,18 @@ export type TidyDryRunState =
| { kind: "idle" }
| { kind: "running" }
| { kind: "done"; result: AdminTidyDryRunResult }
| { kind: "error"; message: string };
| { kind: "error"; message: string; code?: string };
/** TIDY_RECALL_UNAVAILABLE 是「这一轮**尝试过**向量召回但向量源不可用」的错误码。
*
* 它与「没有候选可比」(成功响应 + `recallMode:""`)和「降级到词项」(`lexical`)
* 是三件不同的事,必须各有各的话。试算的严格口径下这种失败**不回响应体**,因此它
* 只走错误路径 —— 面板在这里把它说出来,而不是让主人对着一句「试算失败」猜。 */
export const TIDY_RECALL_UNAVAILABLE = "tidy_recall_unavailable";
/** TIDY_RECALL_UNAVAILABLE_HINT 是上面那个错误码的补充说明。 */
export const TIDY_RECALL_UNAVAILABLE_HINT =
"这一轮尝试过向量召回、但向量源不可用(试算不做静默降级):它既不等于「没有候选可比」,也没有降级到词项余弦。请检查 embedding 服务后重试。";
const actionLabel: Record<string, string> = {
merge: "并入",
@@ -112,6 +123,50 @@ function CandidateRow({ candidate }: { candidate: AdminTidyDryRunCandidate }): R
);
}
/** RecallSourceTag 渲染「这一轮到底走的是向量还是词项」(契约 v0.91 的 `recallMode`)。
*
* 它是 M6 诊断(`DevDocs/audit/去重失效诊断-2026-10-13.md`)的落点:旧实现按「有没
* 有配 embedder」写这个字段,于是**全量降级被静默报成 `embedding`**,面板据此判读会
* 把「向量不可用」读成「向量正常工作」。取值域与读法(三种态**必须分得清**):
*
* - `embedding` —— 语义向量召回真的用上了(好态);
* - `lexical` —— 全部走零依赖词项余弦(进程没接 embedder ⇒ 降级,命中会更少);
* - `mixed` —— **部分分区降级**、其余走向量(v0.91 新增);
* - `""` —— **没有候选可比**:这一轮压根没算过相似度(不是降级,也不该显示成
* 一个空标签 —— 那会让人以为字段坏了)。
*
* ⚠️ 「尝试过召回但向量源不可用」**不在这里**:试算的严格口径下那种情况直接报错
* (`error.code = tidy_recall_unavailable`),响应体根本不会出现;线上也没有
* `recall_unavailable_partitions` 这个键(它只存在于后端日志行)。因此本组件不许
* 用「—」把空串含糊掉 —— 每个取值都有一句可读的中文。 */
export function recallSourceLabel(mode: string): string {
switch (mode) {
case "embedding":
return "召回来源:语义向量(embedding)";
case "lexical":
return "召回来源:词项余弦(未接向量,已降级)";
case "mixed":
return "召回来源:部分分区降级(其余走向量)";
case "":
return "召回来源:无候选可比(本轮未做召回)";
default:
return `召回来源:${mode}(未登记取值)`;
}
}
/** recallSourceTone 给上面那句话配色调:好态 accent、降级 warn、无候选 neutral。 */
export function recallSourceTone(mode: string): "accent" | "warn" | "neutral" {
switch (mode) {
case "embedding":
return "accent";
case "lexical":
case "mixed":
return "warn";
default:
return "neutral";
}
}
/** ResultBody 渲染一次试算的完整结果(有候选 / 空 / 观测口径)。 */
function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactNode {
const vetoReasons = result.vetoReasons ?? {};
@@ -119,7 +174,7 @@ function ResultBody({ result }: { result: AdminTidyDryRunResult }): React.ReactN
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-3">
<Tag tone="ok">算完了,一行都没写</Tag>
<Tag tone={result.recallMode === "embedding" ? "accent" : "warn"}>召回来源:{result.recallMode}</Tag>
<Tag tone={recallSourceTone(result.recallMode)}>{recallSourceLabel(result.recallMode)}</Tag>
<span className="text-[11px] text-[var(--mu-text-lo)]">
扫描 {result.scanned} / 比对 {result.compared} / 召回 {result.pairs} 对 / 否决 {result.vetoed} 对 · 用时{" "}
{result.durationMs} ms · 规则 {result.ruleVersion} · 分区 {result.partitions}
@@ -174,6 +229,9 @@ function StateBody({ state }: { state: TidyDryRunState }): React.ReactNode {
return (
<div className="space-y-1">
<Notice tone="err">试算失败:{state.message}</Notice>
{state.code === TIDY_RECALL_UNAVAILABLE ? (
<span className="text-[11px] text-[var(--mu-text-mid)]">{TIDY_RECALL_UNAVAILABLE_HINT}</span>
) : null}
<span className="text-[11px] text-[var(--mu-text-lo)]">
{TIDY_DRY_RUN_ERROR_HINT}。这里刻意不显示候选数 —— 把失败画成「0 条」就是把「没算出来」冒充成结论。
</span>
@@ -223,7 +281,11 @@ export function MemoryTidyDryRunCard(): React.ReactNode {
})
.catch((e: unknown) => {
log("memory.tidy.dryRun", "error");
setState({ kind: "error", message: e instanceof Error ? e.message : "试算失败" });
setState({
kind: "error",
message: e instanceof Error ? e.message : "试算失败",
code: e instanceof ApiError ? e.code : undefined,
});
});
};
@@ -0,0 +1,124 @@
// 记忆整理「调优」卡的**真实渲染**测试(切片 MEMPANEL / M4)。
//
// 它钉住三件**文案与呈现**的事(逻辑那一半见 memoryTidyTuning.test.ts):
// ① 7 个键都有中文标签 + 各自的兜底说明(「留空 ⇒ 跟随谁」);
// ② 热生效与否**如实**:`hot:false` 必须写「重启后端后生效」,绝不写成「已生效」;
// ③ 坏值**就地报错**并拦住保存(后端那道 400 仍在,但不让主人白跑)。
//
// 跑法(与 MemoryTidyDryRunCard.render.test.ts 同):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/MemoryTidyTuningCard.render.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-memory-tidy-tuning.render.test.cjs \
// && node /tmp/cyrene-memory-tidy-tuning.render.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { AdminMemoryTidyTuningView } from "../../lib/api/adminMemories";
import {
MemoryTidyTuningBody,
TIDY_TUNING_COLD_LABEL,
TIDY_TUNING_DISCLAIMER,
TIDY_TUNING_HOT_LABEL,
} from "./MemoryTidyTuningCard";
import { draftFromView, TUNING_LABELS } from "./memoryTidyTuning";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function has(haystack: string, needle: string): boolean {
return haystack.includes(needle);
}
/** fixtureView 造一份 GET 响应形状的数据(含全部 7 个键的规格)。 */
function fixtureView(overrides: Partial<AdminMemoryTidyTuningView> = {}): AdminMemoryTidyTuningView {
return {
path: "config/memory-tidy.json",
present: true,
hot: false,
hotError: "运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效",
specs: [
{ key: "intervalSec", kind: "int", unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" },
{ key: "quietIntervalSec", kind: "int", unit: "sec", min: 1, fallback: "panel:intervalSec" },
{ key: "quietStartHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" },
{ key: "quietEndHour", kind: "int", unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" },
{ key: "ingestGate", kind: "bool", fallback: "code-default" },
{ key: "autoApply", kind: "bool", fallback: "code-default" },
{ key: "tempTtlSec", kind: "int", unit: "sec", min: 1, fallback: "code-default" },
],
values: { intervalSec: 10800, quietStartHour: 23, quietEndHour: 7, ingestGate: true, autoApply: true },
envValues: { intervalSec: 86400, quietStartHour: 23, quietEndHour: 7 },
...overrides,
};
}
function renderBody(view: AdminMemoryTidyTuningView, draft: Record<string, string>, errors: Record<string, string> = {}, extra: Record<string, unknown> = {}): string {
return renderToStaticMarkup(
createElement(MemoryTidyTuningBody, {
view,
draft,
errors,
busy: false,
receipt: null,
savedView: null,
onDraftChange: () => {},
onSave: () => {},
...extra,
} as never)
);
}
const view = fixtureView();
const html = renderBody(view, draftFromView(view), {}, { receipt: "已保存:平时整理间隔。", savedView: { ...view, hot: true } });
// ── ① 7 个键:中文标签 + 键名 + 兜底说明 ───────────────────────────────────
for (const key of Object.keys(TUNING_LABELS)) {
ok(has(html, TUNING_LABELS[key]), `必须渲染 ${key} 的中文标签`);
ok(has(html, key), `必须显示键名 ${key}(后端契约的键,面板不改名)`);
}
ok(has(html, TIDY_TUNING_DISCLAIMER), "恒显示「面板配置文件优先,.env 只做兜底」");
ok(has(html, "config/memory-tidy.json"), "必须显示面板配置文件的落点");
ok(has(html, "跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "缺位的键要写清「留空 ⇒ 跟随谁」(env 档)");
ok(has(html, "当前 86400"), "env 档的当前生效值要如实带出来");
ok(has(html, "跟随面板里的 intervalSec"), "quietIntervalSec 的下一档是面板内的 intervalSec");
ok(has(html, "跟随代码默认"), "ingestGate/autoApply/tempTtlSec 的下一档是代码默认");
ok(has(html, "≈ 3 小时"), "秒数要给可读提示(10800 ⇒ 3 小时)");
// ── ② 热生效与否:两种口径必须分得清 ───────────────────────────────────────
ok(has(html, TIDY_TUNING_HOT_LABEL), "hot:true 时必须说「已热生效(不用重启后端)」");
ok(has(html, "保存后回显"), "保存后必须回显**从磁盘重新读回**的真值");
ok(has(html, "intervalSec = 10800"), "回显要逐键给出真值");
const coldHtml = renderBody(view, draftFromView(view), {}, {
receipt: "已保存:平时整理间隔。",
savedView: view, // hot: false
});
ok(has(coldHtml, TIDY_TUNING_COLD_LABEL), "hot:false 时必须说「已保存到面板配置文件,重启后端后生效」");
ok(!has(coldHtml, TIDY_TUNING_HOT_LABEL), "hot:false 时绝不许出现「已热生效」");
ok(has(coldHtml, "运行期没有接线可热生效的实现"), "hot:false 要带出后端给的 hotError 原因");
// ── ③ 坏值就地报错并拦住保存 ───────────────────────────────────────────────
const badHtml = renderBody(view, { ...draftFromView(view), intervalSec: "0" }, {
intervalSec: "平时整理间隔 必须 ≥ 1 秒",
});
ok(has(badHtml, "必须 ≥ 1 秒"), "坏值要就地显示错误文案");
ok(has(badHtml, "有坏值,未提交"), "有坏值时必须明说「未提交」(盘上不会被动)");
ok(has(badHtml, "disabled"), "有坏值时保存按钮必须禁用");
ok(has(badHtml, "清空一个键 = 从面板文件里删掉它"), "要解释「清空 ≠ 写 0」这条三态口径");
ok(has(badHtml, "T1 判定"), "要说明同文件其它旋钮原样保留(这张卡只写 7 个键)");
// 缺位下的盘上值:没配 ⇒ 面板显示「未写 ⇒ 跟随兜底」,不编一个 0。
const missing = fixtureView({ values: {} });
const missingHtml = renderBody(missing, draftFromView(missing));
ok(has(missingHtml, "留空 ⇒ 跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC"), "没配的键要显示「留空 ⇒ 跟随下一档」");
ok(!has(missingHtml, "intervalSec = 0"), "没配的键绝不能显示成 0(那是「写了 0」的坏值形态)");
// eslint-disable-next-line no-console
console.log(`MemoryTidyTuningCard.render: ${checks} checks passed`);
@@ -0,0 +1,273 @@
import { useCallback, useEffect, useState } from "react";
import {
adminMemoryTidyTuning,
adminMemoryTidyTuningSave,
type AdminMemoryTidyTuningView,
} from "../../lib/api/adminMemories";
import { Button, Surface, Tag } from "../../components/primitives";
import { Notice } from "../components";
import { useAuditStore } from "../audit";
import type { TuningDraft } from "./memoryTidyTuning";
import {
TUNING_LABELS,
buildTuningPatch,
draftFromView,
fallbackLabel,
patchSummary,
secondsHint,
unitLabel,
} from "./memoryTidyTuning";
/**
* 记忆整理「调优」卡(切片 MEMPANEL / M4)。真源:
* internal/services/gateway/admin_memory_tuning.go。
*
* 它回答的是主人的两句原话:「`.env` 只做兜底用 …… 后端自动创建独立的面板配置文件
* 之后就走面板配置文件,这些键优先从面板配置上取」与「频率必须调,可以调到 1 小时
* 或者三小时,或者动态分配,比如白天间隔短一点、晚上静默期稍微长一点」。
*
* 三条口径落在文案里,因为它们是这张卡的产品承诺:
* 1. **面板优先、`.env` 兜底**:留空 = 不写这个键 ⇒ 回落 `.env` → 代码默认。
* 每个键都写清「跟随谁」,不做含糊的「未设置」;
* 2. **坏值就地报错**:校验规则来自后端的 specs(类型/单位/上下界),非法草稿
* **不发请求**(后端那道 400 仍在,只是不让主人白跑);
* 3. **热生效与否如实说**:后端回 `hot:false` 时写「重启后端后生效」,绝不写成
* 「已生效」。
*
* 呈现与取数分层:`MemoryTidyTuningBody` 只吃状态(渲染测试直接喂 fixture),卡组件
* 负责取数 + 表单草稿 + 保存。
*/
/** 这张卡的核心承诺(渲染测试钉住它必须出现)。 */
export const TIDY_TUNING_DISCLAIMER = "面板配置文件优先,.env 只做兜底";
/** 热生效与「要重启」两种文案(测试分别钉住)。 */
export const TIDY_TUNING_HOT_LABEL = "已热生效(不用重启后端)";
export const TIDY_TUNING_COLD_LABEL = "已保存到面板配置文件,重启后端后生效";
/** BodyProps 是展示半边的输入(全部状态由外面给,便于直接渲染断言)。 */
export interface TuningBodyProps {
view: AdminMemoryTidyTuningView;
draft: TuningDraft;
errors: Record<string, string>;
busy: boolean;
/** 保存成功的回执文案(含热生效与否);null = 还没保存过。 */
receipt: string | null;
/** 保存后回显的真值(后端**重新读盘**的结果)。 */
savedView: AdminMemoryTidyTuningView | null;
onDraftChange: (key: string, value: string) => void;
onSave: () => void;
}
/** hotLabel 返回「这次到底生没生效」的诚实文案。 */
export function hotLabel(view: AdminMemoryTidyTuningView): string {
return view.hot ? TIDY_TUNING_HOT_LABEL : TIDY_TUNING_COLD_LABEL;
}
/** fallbackHint 返回「留空时跟随谁 + 兜底那一档当前是多少」(有就报数)。 */
function fallbackHint(view: AdminMemoryTidyTuningView, key: string, fallback: string): string {
const envValue = view.envValues?.[key];
const base = fallbackLabel({ key, kind: "int", fallback });
return envValue === undefined ? base : `${base}(当前 ${envValue})`;
}
/** FieldRow 渲染一个键:中文标签 + 控件 + 兜底说明 + 就地错误。 */
function FieldRow({
spec,
value,
error,
fallback,
valueHint,
onChange,
}: {
spec: AdminMemoryTidyTuningView["specs"][number];
value: string;
error: string;
fallback: string;
valueHint: string;
onChange: (next: string) => void;
}): React.ReactNode {
const label = TUNING_LABELS[spec.key] ?? spec.key;
const inputCls =
"w-40 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]";
return (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)]/60 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-56 text-xs text-[var(--mu-text-hi)]">{label}</span>
{spec.kind === "bool" ? (
<select
value={value}
className="h-9 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 text-sm outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
onChange={(e) => onChange(e.target.value)}
>
<option value="">跟随兜底</option>
<option value="true">开</option>
<option value="false">关</option>
</select>
) : (
<input
value={value}
inputMode="numeric"
placeholder="留空 = 跟随兜底"
className={inputCls}
onChange={(e) => onChange(e.target.value)}
/>
)}
{valueHint ? <span className="text-[11px] text-[var(--mu-text-lo)]">{valueHint}</span> : null}
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{spec.key}</span>
</div>
<div className="text-[11px] text-[var(--mu-text-lo)]">
{value.trim() === "" ? `留空 ⇒ ${fallback}` : `写进面板配置文件(覆盖 ${fallback})`}
{spec.kind === "int" && unitLabel(spec) ? ` · 单位:${unitLabel(spec)}` : ""}
</div>
{error ? <div className="text-[11px] text-[var(--mu-err)]">⚠ {error}</div> : null}
</div>
);
}
/** MemoryTidyTuningBody 是展示半边:吃状态、不取数(渲染测试直接喂它)。 */
export function MemoryTidyTuningBody({
view,
draft,
errors,
busy,
receipt,
savedView,
onDraftChange,
onSave,
}: TuningBodyProps): React.ReactNode {
const hasErrors = Object.keys(errors).length > 0;
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<Tag tone="accent">{TIDY_TUNING_DISCLAIMER}</Tag>
<span className="font-mono text-[11px] text-[var(--mu-text-lo)]">{view.path}</span>
<span className="text-[11px] text-[var(--mu-text-lo)]">
{view.present ? "面板配置已存在" : "面板配置还没创建(后端下次启动会自动创建)"}
</span>
</div>
{view.specs.map((spec) => (
<FieldRow
key={spec.key}
spec={spec}
value={draft[spec.key] ?? ""}
error={errors[spec.key] ?? ""}
fallback={fallbackHint(view, spec.key, spec.fallback)}
valueHint={spec.unit === "sec" && /^\d+$/.test((draft[spec.key] ?? "").trim())
? secondsHint(Number((draft[spec.key] ?? "").trim()))
: ""}
onChange={(next) => onDraftChange(spec.key, next)}
/>
))}
<div className="flex flex-wrap items-center gap-2">
<Button variant="primary" disabled={busy || hasErrors} onClick={onSave}>
{busy ? "保存中…" : "保存调优"}
</Button>
{hasErrors ? (
<span className="text-[11px] text-[var(--mu-text-lo)]">有坏值,未提交(后端一个字都不会改)</span>
) : null}
</div>
{receipt ? <Notice>{receipt}</Notice> : null}
{savedView ? (
<div className="space-y-1 rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-3 py-2 text-[11px]">
<span className="text-[var(--mu-text-mid)]">保存后回显(从磁盘重新读回的真值):</span>
{savedView.specs.map((spec) => {
const v = savedView.values[spec.key];
return (
<div key={spec.key} className="font-mono text-[var(--mu-text-lo)]">
{spec.key} = {v === undefined ? "(未写 ⇒ 跟随兜底)" : String(v)}
</div>
);
})}
<div className="text-[var(--mu-text-mid)]">{hotLabel(savedView)}</div>
{savedView.hot ? null : (
<div className="text-[var(--mu-text-lo)]">{savedView.hotError ?? "运行期未接线可热生效的实现"}</div>
)}
</div>
) : null}
<p className="text-[11px] text-[var(--mu-text-lo)]">
只写这张卡的 7 个键;文件里其它旋钮(T1 判定 / T1 限速 / 每轮自动落地上限)原样保留。
清空一个键 = 从面板文件里删掉它(回到下一档兜底),不是写 0。
</p>
</div>
);
}
/** MemoryTidyTuningCard 负责取数、表单草稿与保存。 */
export function MemoryTidyTuningCard(): React.ReactNode {
const [view, setView] = useState<AdminMemoryTidyTuningView | null>(null);
const [draft, setDraft] = useState<TuningDraft>({});
const [errors, setErrors] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [fail, setFail] = useState<string | null>(null);
const [receipt, setReceipt] = useState<string | null>(null);
const [savedView, setSavedView] = useState<AdminMemoryTidyTuningView | null>(null);
const log = useAuditStore((s) => s.log);
const reload = useCallback(() => {
setFail(null);
return adminMemoryTidyTuning()
.then((next) => {
setView(next);
setDraft(draftFromView(next));
})
.catch((e: unknown) => setFail(e instanceof Error ? e.message : "读取面板配置失败"));
}, []);
useEffect(() => {
void reload();
}, [reload]);
const onDraftChange = useCallback((key: string, value: string) => {
setDraft((prev) => ({ ...prev, [key]: value }));
setReceipt(null);
}, []);
const onSave = useCallback(() => {
if (!view) return;
const check = buildTuningPatch(view, draft);
setErrors(check.errors);
if (Object.keys(check.errors).length > 0) return;
setBusy(true);
setFail(null);
adminMemoryTidyTuningSave(check.patch)
.then((next) => {
// 响应里的值是后端**重新读盘**的结果:据此重铺草稿(回显真值)。
setView(next);
setDraft(draftFromView(next));
setSavedView(next);
setErrors({});
setReceipt(`已保存:${patchSummary(check.patch)}。${hotLabel(next)}`);
log("memory.tidy.tuning", "ok");
})
.catch((e: unknown) => {
setFail(e instanceof Error ? e.message : "保存面板配置失败");
log("memory.tidy.tuning", "error");
})
.finally(() => setBusy(false));
}, [view, draft, log]);
return (
<Surface className="space-y-3">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">记忆整理调优(面板配置)</h3>
<Tag tone="neutral">GET / PUT /api/v1/admin/memories/tidy/tuning</Tag>
</div>
{fail ? <Notice tone="err">{fail}</Notice> : null}
{view === null ? (
<span className="text-xs text-[var(--mu-text-lo)]">{fail ? "读取失败" : "读取中…"}</span>
) : (
<MemoryTidyTuningBody
view={view}
draft={draft}
errors={errors}
busy={busy}
receipt={receipt}
savedView={savedView}
onDraftChange={onDraftChange}
onSave={onSave}
/>
)}
</Surface>
);
}
@@ -0,0 +1,167 @@
// 记忆整理「调优」卡**纯逻辑**的测试(切片 MEMPANEL / M4)。
//
// 为什么需要它:这张卡最容易写错的地方不是样式,而是**三态**(键缺位 / 合法值 /
// 非法值)与「清空 ≠ 不改」这两条 —— 错一处就会写出「主人以为改了、盘上没动」或者
// 「一个坏值把整份配置写成 0」这类事故。这些判据全在 memoryTidyTuning.ts 的纯函数里,
// 因此不需要渲染就能钉住。
//
// 跑法(与同目录 *.render.test.ts 同一手法,本文件不依赖 React):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/memoryTidyTuning.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-memory-tidy-tuning.test.cjs \
// && node /tmp/cyrene-memory-tidy-tuning.test.cjs
//
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
import type {
AdminMemoryTidyTuningSpec,
AdminMemoryTidyTuningView,
} from "../../lib/api/adminMemories";
import {
buildTuningPatch,
draftFromView,
fallbackLabel,
fieldError,
patchSummary,
secondsHint,
} from "./memoryTidyTuning";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function eq<T>(got: T, want: T, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what}:got=${JSON.stringify(got)} want=${JSON.stringify(want)}`);
}
function deepEq(got: unknown, want: unknown, what: string): void {
const g = JSON.stringify(got);
const w = JSON.stringify(want);
checks += 1;
if (g !== w) throw new Error(`FAILED: ${what}:got=${g} want=${w}`);
}
/** spec 是后端 specs 的替身(取值规则来自后端,前端只消费)。 */
function spec(key: string, kind: string, extra: Partial<AdminMemoryTidyTuningSpec> = {}): AdminMemoryTidyTuningSpec {
return { key, kind, fallback: "code-default", ...extra } as AdminMemoryTidyTuningSpec;
}
/** view 造一份 GET 响应形状的 fixture。 */
function view(values: Record<string, number | boolean>, envValues?: Record<string, number>): AdminMemoryTidyTuningView {
return {
path: "config/memory-tidy.json",
present: true,
hot: false,
hotError: "运行期没有接线可热生效的实现",
specs: [
spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "env:MEMORY_TIDY_INTERVAL_SEC" }),
spec("quietIntervalSec", "int", { unit: "sec", min: 1, fallback: "panel:intervalSec" }),
spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_START_HOUR" }),
spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "env:MEMORY_TIDY_QUIET_END_HOUR" }),
spec("ingestGate", "bool", { fallback: "code-default" }),
spec("autoApply", "bool", { fallback: "code-default" }),
spec("tempTtlSec", "int", { unit: "sec", min: 1, fallback: "code-default" }),
],
values,
envValues,
};
}
// ① 草稿:键缺位 ⇒ 空串(= 跟随兜底),而不是 0 —— 「没配」与「配成 0」必须分得开。
{
const draft = draftFromView(view({ intervalSec: 10800, ingestGate: false }));
eq(draft.intervalSec, "10800", "草稿回填盘上值");
eq(draft.ingestGate, "false", "布尔键回填为字符串");
eq(draft.quietIntervalSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)");
eq(draft.tempTtlSec, "", "盘上没这个键 ⇒ 空串(跟随兜底)");
}
// ② 三个态:没动过的键**不进补丁**(= 不改动);改过的进补丁;合法值按类型提交。
{
const base = view({ intervalSec: 86400, quietStartHour: 23, quietEndHour: 7, ingestGate: false });
const draft = draftFromView(base);
const untouched = buildTuningPatch(base, draft);
deepEq(untouched.patch, {}, "没动过任何键 ⇒ 空补丁(盘上逐字节不动)");
deepEq(untouched.errors, {}, "没动过任何键 ⇒ 无错误");
const changed = buildTuningPatch(base, { ...draft, intervalSec: "10800", ingestGate: "true" });
deepEq(changed.patch, { intervalSec: 10800, ingestGate: true }, "合法值 ⇒ 采用(且只含改动过的键)");
deepEq(changed.errors, {}, "合法值 ⇒ 无错误");
}
// ③ 清空哨兵:原本有值、现在清空 ⇒ `null`(删掉该键,回到下一档兜底);
// 原本就没值、现在还是空 ⇒ 什么都不发(不改动)。
{
const base = view({ intervalSec: 3600, tempTtlSec: 604800 });
const draft = draftFromView(base);
const cleared = buildTuningPatch(base, { ...draft, intervalSec: "" });
deepEq(cleared.patch, { intervalSec: null }, "清空一个有值的键 ⇒ null(回到兜底)");
const alreadyEmpty = view({ ingestGate: true });
const mix = view({ ingestGate: true });
const r2 = buildTuningPatch(mix, { ...draftFromView(alreadyEmpty), tempTtlSec: "" });
deepEq(r2.patch, {}, "原本就没值的键保持空 ⇒ 不改动(不是清除)");
}
// ④ 非法值:**就地报错且不进补丁**(界面据此拦下保存),后端的 400 仍是权威那道门。
{
const base = view({ intervalSec: 3600, quietStartHour: 23, quietEndHour: 7 });
const draft = draftFromView(base);
const cases: Array<[string, string, string]> = [
["intervalSec", "abc", "非整数"],
["intervalSec", "0", "低于下界(0 会让整理空转)"],
["intervalSec", "-60", "负数"],
["quietStartHour", "24", "钟点越上界"],
["quietEndHour", "-1", "钟点越下界"],
["tempTtlSec", "1.5", "小数不是整数"],
];
for (const [key, raw, why] of cases) {
const res = buildTuningPatch(base, { ...draft, [key]: raw });
ok(res.errors[key] !== undefined, `非法值必须就地报错(${why})`);
ok(res.patch[key] === undefined, `非法值绝不能进补丁(${why})`);
}
// 混着来:一个坏值不影响对合法键的校验,但界面整体不许保存(errors 非空)。
const mixed = buildTuningPatch(base, { ...draft, intervalSec: "0", quietStartHour: "22" });
ok(Object.keys(mixed.errors).length === 1, "只有一个坏值时只报一处");
eq(mixed.patch.quietStartHour, 22, "合法键仍照常进补丁(后端一次事务性写盘)");
// 布尔键只认三个取值。
eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), "yes").length > 0, true, "布尔键的坏值要报错");
eq(fieldError(spec("ingestGate", "bool", { fallback: "code-default" }), ""), "", "空 = 跟随兜底,合法");
}
// ⑤ 边界值合法(下界/上界本身可用,不是「越界」)。
{
eq(fieldError(spec("intervalSec", "int", { unit: "sec", min: 1, fallback: "x" }), "1"), "", "下界本身合法");
eq(fieldError(spec("quietStartHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "0"), "", "钟点 0 合法");
eq(fieldError(spec("quietEndHour", "int", { unit: "hour", min: 0, max: 23, fallback: "x" }), "23"), "", "钟点 23 合法");
}
// ⑥ 文案:兜底来源与单位要写成人话(面板的「留空 ⇒ 跟随谁」全靠它)。
{
eq(fallbackLabel(spec("intervalSec", "int", { fallback: "env:MEMORY_TIDY_INTERVAL_SEC" })),
"跟随 .env 的 MEMORY_TIDY_INTERVAL_SEC", "env 兜底文案");
eq(fallbackLabel(spec("quietIntervalSec", "int", { fallback: "panel:intervalSec" })),
"跟随面板里的 intervalSec", "面板内兜底文案");
eq(fallbackLabel(spec("tempTtlSec", "int", { fallback: "code-default" })), "跟随代码默认", "代码默认文案");
eq(secondsHint(10800), "≈ 3 小时", "秒 → 小时提示");
eq(secondsHint(1800), "≈ 30 分钟", "秒 → 分钟提示");
eq(secondsHint(0), "", "非正数不提示");
eq(patchSummary({ intervalSec: 3600, ingestGate: false }), "同步入库闸(写入时当场审查)、平时整理间隔", "回执文案");
eq(patchSummary({}), "没有需要改的键", "空补丁回执");
}
// ⑦ 字符串数字不做隐式转换(" 12 " 这种带空白的要能通过,但 "12 3" 不行)。
{
const base = view({ intervalSec: 3600 });
const padded = buildTuningPatch(base, { ...draftFromView(base), intervalSec: " 7200 " });
deepEq(padded.patch, { intervalSec: 7200 }, "两侧空白应当被容忍");
const bad = buildTuningPatch(base, { ...draftFromView(base), intervalSec: "12 3" });
ok(bad.errors.intervalSec !== undefined, "内部空格不是合法整数");
}
console.log(`memoryTidyTuning.test.ts: ${checks} checks passed`);
@@ -0,0 +1,152 @@
import type {
AdminMemoryTidyTuningPatch,
AdminMemoryTidyTuningSpec,
AdminMemoryTidyTuningView,
} from "../../lib/api/adminMemories";
/* ----------------------------------------------------------------------------
* 记忆整理「面板调优」卡的**纯逻辑**(切片 MEMPANEL / M4):表单草稿 ↔ 三态补丁。
*
* 与呈现分开(与 memoryTidyApplyState.ts 同一手法):这一半不依赖 React,可以直接
* 用 node 跑断言(见 memoryTidyTuning.test.ts)。
*
* 三条不可走样的口径:
* 1. **取值规则来自后端的 specs**(类型/单位/上下界/兜底来源):面板不硬编码第二套
* 枚举或上下界 —— 否则就会出现「面板允许、后端 400」的漂移。本文件只负责把
* spec 翻成**草稿的校验规则**与中文文案。
* 2. **键缺位 ≠ 清空**:草稿里没动过的键**不进补丁**(= 后端不动它);把一个原本
* 有值的键清空 ⇒ 发 `null`(= 后端删掉该键,回到下一档兜底)。两者在盘上的结果
* 完全不同,因此这里必须分开。
* 3. **坏值就地报错**:非法草稿**不产生补丁**(界面据此拦下保存),而不是发出去等
* 后端 400 —— 后端那道门仍在(它是权威),这只是不让主人白跑一趟。
* -------------------------------------------------------------------------- */
/** `true` / `false` / 三个中文标签:调优键的中文名(面板文案)。 */
export const TUNING_LABELS: Record<string, string> = {
intervalSec: "平时整理间隔",
quietIntervalSec: "静默时段整理间隔",
quietStartHour: "静默时段开始(本地钟点)",
quietEndHour: "静默时段结束(本地钟点)",
ingestGate: "同步入库闸(写入时当场审查)",
autoApply: "轮末自动落地(高置信候选直接改记忆)",
tempTtlSec: "临时记忆存活时长",
};
/** 兜底来源的中文说明(后端 specs 的 `fallback` 只有三族)。 */
export function fallbackLabel(spec: AdminMemoryTidyTuningSpec): string {
if (spec.fallback.startsWith("env:")) {
return `跟随 .env 的 ${spec.fallback.slice("env:".length)}`;
}
if (spec.fallback.startsWith("panel:")) {
return `跟随面板里的 ${spec.fallback.slice("panel:".length)}`;
}
return "跟随代码默认";
}
/** 单位的中文说明。 */
export function unitLabel(spec: AdminMemoryTidyTuningSpec): string {
if (spec.unit === "sec") return "秒";
if (spec.unit === "hour") return "本地钟点(0-23)";
return "";
}
/** 秒数的可读提示(面板不改变单位,只帮主人把 10800 读成 3 小时)。 */
export function secondsHint(seconds: number): string {
if (!Number.isFinite(seconds) || seconds <= 0) return "";
if (seconds % 3600 === 0) return `≈ ${seconds / 3600} 小时`;
if (seconds % 60 === 0) return `≈ ${seconds / 60} 分钟`;
return `≈ ${seconds} 秒`;
}
/** 表单草稿:每个键一个字符串(bool 键用 "true"/"false",空串 = 跟随兜底/清空)。 */
export type TuningDraft = Record<string, string>;
/** 草稿的校验结果。 */
export interface TuningDraftCheck {
/** 键 → 就地错误文案(空对象 = 可以保存)。 */
errors: Record<string, string>;
/** 真正要提交的三态补丁(只含改动过的键)。 */
patch: AdminMemoryTidyTuningPatch;
}
/** draftFromView 把盘上的值铺成表单草稿(键缺位 ⇒ 空串 = 跟随兜底)。 */
export function draftFromView(view: AdminMemoryTidyTuningView): TuningDraft {
const draft: TuningDraft = {};
for (const spec of view.specs) {
const value = view.values[spec.key];
if (value === undefined || value === null) {
draft[spec.key] = "";
continue;
}
draft[spec.key] = typeof value === "boolean" ? (value ? "true" : "false") : String(value);
}
return draft;
}
/** fieldError 校验**单个**键的草稿值;返回空串表示合法(空 = 沿用兜底,也合法)。 */
export function fieldError(spec: AdminMemoryTidyTuningSpec, raw: string): string {
const text = raw.trim();
if (text === "") return "";
if (spec.kind === "bool") {
return text === "true" || text === "false" ? "" : `${TUNING_LABELS[spec.key] ?? spec.key} 只能选「开 / 关 / 跟随兜底」`;
}
if (!/^-?\d+$/.test(text)) {
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须是整数(秒),收到「${text}」`;
}
const n = Number(text);
if (spec.min !== undefined && n < spec.min) {
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≥ ${spec.min}${unitLabel(spec)}`;
}
if (spec.max !== undefined && n > spec.max) {
return `${TUNING_LABELS[spec.key] ?? spec.key} 必须 ≤ ${spec.max}${unitLabel(spec)}`;
}
return "";
}
/** buildTuningPatch 是「草稿 → 三态补丁」的唯一实现点(口径 2、3)。 */
export function buildTuningPatch(
view: AdminMemoryTidyTuningView,
draft: TuningDraft
): TuningDraftCheck {
const errors: Record<string, string> = {};
const patch: AdminMemoryTidyTuningPatch = {};
for (const spec of view.specs) {
const raw = draft[spec.key] ?? "";
const err = fieldError(spec, raw);
if (err) {
errors[spec.key] = err;
continue;
}
const current = view.values[spec.key];
const text = raw.trim();
if (text === "") {
// 原本就没有值 ⇒ 一个字节都不必动(后端同样把「键缺位」当「不改动」)。
if (current === undefined || current === null) continue;
// 原本有值、现在清空 ⇒ **清除哨兵**(删掉该键,回到下一档兜底)。
patch[spec.key] = null;
continue;
}
if (spec.kind === "bool") {
const next = text === "true";
if (typeof current === "boolean" && current === next) continue;
patch[spec.key] = next;
continue;
}
const next = Number(text);
if (typeof current === "number" && current === next) continue;
patch[spec.key] = next;
}
return { errors, patch };
}
/** patchKeys 返回补丁里出现的键(供回执文案与日志/审计展示,顺序稳定)。 */
export function patchKeys(patch: AdminMemoryTidyTuningPatch): string[] {
return Object.keys(patch).sort();
}
/** patchSummary 把补丁说成人话(保存后回执用,例如「平时整理间隔、同步入库闸」)。 */
export function patchSummary(patch: AdminMemoryTidyTuningPatch): string {
const keys = patchKeys(patch);
if (keys.length === 0) return "没有需要改的键";
return keys.map((k) => TUNING_LABELS[k] ?? k).join("、");
}
+9
View File
@@ -155,3 +155,12 @@ export {
adminTidyCandidatesApplyBatch,
adminTidyRun,
} from "./api/adminMemoryTidyApply";
export {
adminMemoryTidyTuning,
adminMemoryTidyTuningSave,
} from "./api/adminMemories";
export type {
AdminMemoryTidyTuningPatch,
AdminMemoryTidyTuningSpec,
AdminMemoryTidyTuningView,
} from "./api/adminMemories";
+75
View File
@@ -0,0 +1,75 @@
import { request } from "./core";
/* ----------------------------------------------------------------------------
* 记忆整理**面板调优**(切片 MEMPANEL / M4)。真源:
* internal/services/gateway/admin_memory_tuning.go(形状与错误码)
* internal/services/gateway/admin_memory_tuning_file.go(取值规则 specs)
*
* GET /admin/memories/tidy/tuning 读:盘上 7 键值 + 取值规则
* PUT /admin/memories/tidy/tuning 写:三态补丁(键缺位 = 不改动 / 合法值 = 采用 /
* 非法值 = 400 且盘上不动;`null` = 清掉该键)
*
* 两条都是 writeAuth(owner|admin)——与同族的 `/admin/memories` 同档:它是私有记忆面
* 的运行期旋钮,不是给 viewer 的监控投影。
*
* ⚠️ 失败走 `error.code`,**绝不**回一份「已保存」的假成功:
* - `invalid_payload` 400:值非法 / 未知键(**盘上一个字节都没动**)
* - `tuning_corrupt` 409:盘上那份配置文件读不走 ⇒ 拒绝覆盖(要人工修/删)
* - `tuning_unavailable` 500:数据目录未接线
* - `tuning_write_failed` 500:落盘失败(盘上内容未改动)
*
* 响应里的 `hot` 是**如实**的:true = 保存后当场生效;false = 已落盘,但运行期没接到
* 可热生效的实现(`hotError` 说明原因),此时界面必须写「重启后端后生效」。
* -------------------------------------------------------------------------- */
/** 调的落点(面板配置文件的**相对**路径,形如 `config/memory-tidy.json`)。 */
const TUNING_PATH = "/admin/memories/tidy/tuning";
/** 一个可写键的取值规则(后端下发,面板不另抄一份枚举/上下界)。 */
export interface AdminMemoryTidyTuningSpec {
/** 键名,如 `intervalSec` / `ingestGate`。 */
key: string;
/** `int` | `bool`(面板据此选控件类型)。 */
kind: string;
/** `sec` | `hour`(仅 int 键;面板据此写单位)。 */
unit?: string;
min?: number;
max?: number;
/** 缺位时生效值从哪来:`env:MEMORY_TIDY_*` / `panel:<key>` / `code-default`。 */
fallback: string;
}
/** GET / PUT 的 data(PUT 的响应是**重新读盘**的结果,即真值回显)。 */
export interface AdminMemoryTidyTuningView {
/** 面板配置文件相对 `$DATA_DIR` 的路径。 */
path: string;
/** 文件是否已存在且有内容(false = 还没播种,后端下次启动会自动创建)。 */
present: boolean;
/** 本次结果是否**当场生效**。false = 已落盘,要等后端重启(见 hotError)。 */
hot: boolean;
/** `hot:false` 时的原因。 */
hotError?: string;
specs: AdminMemoryTidyTuningSpec[];
/** **盘上**的值:键缺位 ⇒ 该键不出现(= 跟随兜底),不是 0。 */
values: Record<string, number | boolean>;
/** 缺位时**下一档(`.env`)**的进程生效值(与 /admin/config-facts 同源)。 */
envValues?: Record<string, number>;
}
/** 写补丁:只放**真的要改**的键;`null` = 清掉该键(回到下一档兜底)。 */
export type AdminMemoryTidyTuningPatch = Record<string, number | boolean | null>;
/** GET /api/v1/admin/memories/tidy/tuning —— 读盘上的调优值 + 取值规则。 */
export function adminMemoryTidyTuning(): Promise<AdminMemoryTidyTuningView> {
return request<AdminMemoryTidyTuningView>(TUNING_PATH);
}
/** PUT /api/v1/admin/memories/tidy/tuning —— 三态补丁写盘(并尽量热生效)。 */
export function adminMemoryTidyTuningSave(
patch: AdminMemoryTidyTuningPatch
): Promise<AdminMemoryTidyTuningView> {
return request<AdminMemoryTidyTuningView>(TUNING_PATH, {
method: "PUT",
body: JSON.stringify(patch),
});
}
@@ -0,0 +1,150 @@
// memorytidy_save.go 是 `$DATA_DIR/config/memory-tidy.json` 的**写路径**(面板调优)。
//
// 为什么需要它(主人 2026-10-07 的两条口径,逐字):「`.env` 只做兜底用 在除了第1次
// 部署之后 后端自动创建 独立的面板配置文件之后就走面板配置文件。这些键优先从面板
// 配置上取」+「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短
// 一点 晚上到了静默期稍微长一点」。在这条写路径出现之前,那个文件**只有读路径**
// (LoadMemoryTidyTuning + 启动播种 SeedMemoryTidy):主人要改频率只能改文件 + 重启。
//
// 它与读取侧(memorytidy.go)是**同一份文件形状的唯一两个门**:decodeMemoryTidy
// 仍是判「这份文件能不能被读走」的唯一那一行代码,写路径不另立一套判定。
//
// 四条口径(与既有的 SaveChannels / SaveWebSearch 同族同手法):
//
// 1. **补丁语义**:调用方给的是「要设的键(原样 JSON 值)+ 要删的键」,不是整份
// 文档。盘上其它键(`t1Judge` / `autoApplyPerRound` …以及将来新增的键)**原样
// 保留** —— 一次面板保存绝不把主人手写进文件的其它旋钮抹掉。
// 2. **值不变就不落盘**:合并后的文档与盘上那份**语义相同**(同一个 MarshalIndent
// 口径比较,而不是比原始字节)时**一个字节都不写**、连 mtime 都不动 —— 主人的
// 手写排版不该因为一次空操作被重排。
// 3. **读不走的文件拒绝覆盖**:文件存在但不是 JSON 对象 / 结构解不开 ⇒ 返回
// `ErrInvalidMemoryTidy`,调用方应**原样报错并拒绝写**(盘上此刻运行期正整体
// 回落 `.env`/代码默认,盖掉它只会掩盖问题)。
// 4. **原子写**:temp + rename(文件 0600 / 目录 0755,与 SeedMemoryTidy、
// SaveWebSearch 一致):并发读者要么看到旧文件、要么看到新文件,不会读到半个
// JSON;且**先写好再换名**,任何一步失败都不会留下半份配置。
//
// 格式上的代价(如实登记):补丁经 `map[string]json.RawMessage` 落盘 ⇒ 键序变成
// 字典序、缩进统一为 2 空格。每个键的值一个不差,但人工排过版的文件在**真正发生
// 改动时**会被重排一次(口径 2 保证「没改动就不重排」)。
package runtimeconfig
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
// memoryTidySaveFileMode 是落盘文件的权限(只给跑服务的账号读)。
memoryTidySaveFileMode = 0o600
// memoryTidySaveDirMode 是 `$DATA_DIR/config/` 的建目录权限。
memoryTidySaveDirMode = 0o755
)
// PatchMemoryTidyTuning 把一组**已经过调用方校验**的键值并进
// `memory-tidy.json` 并原子写回,返回落盘后**重新读回**的调优结构。
//
// set 的键是「要写的键 → 原样 JSON 值」(调用方负责值的类型/取值域校验:那是 API
// 契约的一部分,属于网关侧;本函数只管**文件形状与落盘**)。clear 是要从文件里删掉
// 的键(= 回到下一档兜底)。两者都为空时**不落盘**。
//
// 返回 (tuning, wrote, err):
// - tuning 是**重新读盘**的结果(真值回显,不是「我以为写成了什么」);
// - wrote 报告这次是否真的写了字节(内容语义未变时为 false);
// - err 非 nil 时**盘上一个字节都没动**;错误链里带 ErrInvalidMemoryTidy 表示
// 「盘上那份读不走」⇒ 调用方应拒绝覆盖并如实报错。
func PatchMemoryTidyTuning(path string, set map[string]json.RawMessage, clear []string) (MemoryTidyTuning, bool, error) {
doc, err := readMemoryTidyDocForPatch(path)
if err != nil {
return MemoryTidyTuning{}, false, err
}
before := canonicalMemoryTidyJSON(doc)
merged := make(map[string]json.RawMessage, len(doc)+len(set))
for key, value := range doc {
merged[key] = value
}
for key, value := range set {
merged[key] = value
}
for _, key := range clear {
delete(merged, key)
}
raw, err := json.MarshalIndent(merged, "", " ")
if err != nil {
return MemoryTidyTuning{}, false, fmt.Errorf("runtimeconfig: marshal memory tidy: %w", err)
}
wrote := false
if !bytes.Equal(raw, before) {
if err := writeMemoryTidyTuningFile(path, raw); err != nil {
return MemoryTidyTuning{}, false, err
}
wrote = true
}
tuning, err := LoadMemoryTidyTuning(path)
if err != nil {
// 刚落盘就读不回来:这是本函数自己的错误(写出去的内容必须能被自己的读取
// 路径读走),如实报错,绝不回一份「成功」。
return MemoryTidyTuning{}, wrote, fmt.Errorf("runtimeconfig: reread %s after patch: %w", path, err)
}
return tuning, wrote, nil
}
// readMemoryTidyDocForPatch 读盘上的文档(键 → 原样 JSON 值)并确认**运行期读得走**。
//
// 缺失/全空白 ⇒ 空文档(= 还没配过,与 LoadMemoryTidyTuning 同义,不是错误);
// 存在但不是 JSON 对象、或整体结构解不开(类型写错)⇒ ErrInvalidMemoryTidy
// (口径 3:拒绝覆盖)。
func readMemoryTidyDocForPatch(path string) (map[string]json.RawMessage, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return map[string]json.RawMessage{}, nil
}
return nil, fmt.Errorf("runtimeconfig: read %s: %w", path, err)
}
if strings.TrimSpace(string(raw)) == "" {
return map[string]json.RawMessage{}, nil
}
// 与读取路径**同一行代码**判「这份文件能不能被读走」:一处说坏了、另一处说没事
// 就是自相矛盾(写入方会据此把一份坏配置盖掉)。
if _, err := decodeMemoryTidy(raw, path); err != nil {
return nil, err
}
doc := map[string]json.RawMessage{}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("%w: decode %s as object: %w", ErrInvalidMemoryTidy, path, err)
}
return doc, nil
}
// canonicalMemoryTidyJSON 返回文档的规范化字节(与落盘同一个 MarshalIndent 口径),
// 供「值有没有变」的语义比较用。序列化失败时返回 nil —— 那只可能让比较不相等
// (= 走正常落盘路径),不会误判成「没变」。
func canonicalMemoryTidyJSON(doc map[string]json.RawMessage) []byte {
raw, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return nil
}
return raw
}
// writeMemoryTidyTuningFile 原子写出文件(口径 4):建目录 → 写临时文件 → rename。
func writeMemoryTidyTuningFile(path string, raw []byte) error {
if dir := filepath.Dir(path); dir != "" && dir != "." {
if err := os.MkdirAll(dir, memoryTidySaveDirMode); err != nil {
return fmt.Errorf("runtimeconfig: mkdir %s: %w", dir, err)
}
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, raw, memoryTidySaveFileMode); err != nil {
return fmt.Errorf("runtimeconfig: write %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("runtimeconfig: rename %s: %w", path, err)
}
return nil
}
@@ -8,6 +8,8 @@ import (
"fmt"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是入库前审查(design/23 §15.1 D3)的存储层:候选读取、软合并落定、
@@ -89,6 +91,24 @@ func (s *Store) ingestReviewer() *PreInsertReviewer {
return s.review
}
// ApplyIngestPanelTuning 把面板值**当场**叠到已接入的入库前审查上(切片 MEMPANEL / M4)。
//
// 网关握着的是记忆存储(`deps.Memories`),而不是审查服务本身,因此这条转调就是面板
// 写端点触达审查侧的**唯一**通道(不为此新开依赖面:没有新的 Deps 字段、没有新的
// Server 字段)。
//
// 未接入审查(`MEMORY_REVIEW_ENABLED=false`)时是**空操作 + nil**:那时两个键本来
// 就不生效,运行期没有任何旧值要改,返回错误只会让一次完全正常的保存报「热生效失败」。
// 指针的读写走既有的 `reviewMu`(与 AttachIngestReviewer 同一把锁),不会与并发接入
// 交手。
func (s *Store) ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
reviewer := s.ingestReviewer()
if reviewer == nil {
return nil
}
return reviewer.ApplyPanelTuning(panel)
}
// enqueueReview 把刚落库的条目投入审查队列。非阻塞:队列满时审查方自己记
// WARN(它才是唯一知道队列状态的一侧),写入方绝不因此阻塞或失败。
func (s *Store) enqueueReview(item MemoryItem) {
@@ -68,3 +68,33 @@ func panelPositiveTTLSec(v *int, key string, log *slog.Logger) int {
}
return *v
}
// ApplyPanelTuning 把面板值**当场**叠到已经在跑的审查服务上(切片 MEMPANEL / M4)。
//
// 它只碰面板管得到、且**在 `.env` 里结构性没有出处**的两个键(`ingestGate` /
// `tempTtlSec`)—— 因此重算起点就是这两个字段的**零值**(中档为空),不需要一份
// 「`.env` 快照」,也不会因为重算而误伤别的键:`Enabled` / `QueueSize` / `ScanLimit` /
// `Timeout` / `Threshold` / T1 四件套一律**原样不动**(它们来自部署配置,不是面板旋钮;
// 面板端点也拒收它们)。
//
// 口径与装配期逐字一致(`wireMemoryReview` → `NewPreInsertReviewer`):面板写了就用,
// 缺位/坏值由 `ApplyReviewTuning`(WARN + 不覆盖)与 `normalizeReviewConfig`(代码
// 默认)接住 —— 这两处仍是**唯一**实现点,本方法不另写一套回落规则。
//
// 🔴 并发边界(如实登记):写 `r.cfg` 的两处在 `r.mu` 下,但 `preinsert.go` /
// `preinsert_gate.go` 读 `r.cfg` 的几处**没有**取 `r.mu`(本切片 scope 不含那两个
// 文件,故未一并收口)。这两个字段分别是一个 bool 与一个 int64,在 64 位平台上单字长
// 写入不会撕裂,但严格意义上这仍是「写方持锁、读方不持锁」的窄窗口。把 cfg 读改成
// 访问器(或换成原子快照)是后续应做的一次收口,已在回报里登记。
func (r *PreInsertReviewer) ApplyPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
next := normalizeReviewConfig(ApplyReviewTuning(ReviewConfig{}, panel, r.log))
r.mu.Lock()
r.cfg.DisableIngestGate = panel.IngestGateOff()
r.cfg.TempTTL = next.TempTTL
gateOn := !r.cfg.DisableIngestGate
ttl := r.cfg.TempTTL
r.mu.Unlock()
r.log.Info("memory review panel tuning applied",
"ingest_gate", gateOn, "temp_ttl", ttl.String(), "source", "admin panel")
return nil
}
@@ -0,0 +1,143 @@
package memory
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是**整理自动档的运行期调优面**(切片 MEMPANEL / M4):把
// `$DATA_DIR/config/memory-tidy.json` 的面板旋钮**当场**叠到已经在跑的 TidyService 上,
// 使主人点一次「保存」就生效,而不必重启后端。
//
// 为什么需要它(主人 2026-10-07 两条口径,逐字):
//
// 「`.env` 只做兜底用 在除了第1次部署之后 后端自动创建 独立的面板配置文件之后就走
// 面板配置文件。这些键优先从面板配置上取」
// 「频率必须调 可以调到 1 小时或者三小时 或者动态分配,比如白天是间隔短一点 晚上到
// 了静默期稍微长一点」
//
// 在这之前,面板文件只在**装配期**被读一次(`wireMemoryTidy` → `NewTidyService`),
// 四个调度旋钮与 autoApply 被**拷进**服务的 `cfg` 字段后就不再变 —— 面板改完文件,
// 跑着的进程一个字节都不会变。
//
// 三条不可走样的口径:
//
// 1. **重算而不是叠加**:`base` 是 `.env` 层(由组合根用 `TidyConfigFromConfig`
// 从生效配置投影而来),`panel` 是刚落盘的那一份 ⇒ 结果是「面板 > `.env` >
// 代码默认」。**不从当前 cfg 上叠**:主人把某个键清掉(`null`)时必须回到
// `.env` 档,而不是留在上一次的面板值上。
// 2. **心跳要重排**:心跳正睡在「用**旧**周期算出来」的那一觉里(`nextDelay` 只在
// 每一拍之前现算),最长可到旧周期。不重排的话,主人把周期从 24 小时改成 3 小时
// 时,新周期要等那一觉睡完才生效 —— 那等于「改了跟没改一样」。因此这里 Stop +
// Start 把当前那次等待作废,并按新配置重新排一次(`catchUp` 会立刻复核「是不是
// 已经到点」,于是缩短周期往往**当场**补跑一轮)。
// 3. **失败不吞值**:重排失败(正在跑一轮、排空超时)时**值照样落到运行期**(下一次
// 调度判定就用它),并把失败如实回给调用方 —— 绝不假装热生效,也绝不让保存白写。
//
// 并发口径(为什么这样写是安全的):`s.runMu` 串行化**所有**轮次(心跳轮 `runRound`
// 与手动轮 / 试算 `DryRun` 都持它),而两个字段的读者只有这些轮次;因此「持 runMu →
// Stop 心跳 → 写 cfg → Start」这一串之后,没有任何 goroutine 会在写入的同时读这两个
// 字段。心跳 goroutine 的调度读(`nextDelay` / `dueNow` 读 `s.cfg`)在 Stop 返回后
// 已经全部结束,因此也不与写入交手。
// memoryTidyRescheduleTimeout 是重排心跳时「等正在跑的那一轮跑完」的上限。
//
// 它存在的理由:`Stop` 要等 wg 排空(可能正有一轮在扫描几百条记忆)。给上限是为了
// 不让一次面板保存把 HTTP 请求挂到天荒地老;超时**如实报错**(值照旧落到运行期),
// 绝不静默假装重排成功。
const memoryTidyRescheduleTimeout = 5 * time.Second
// ErrTidyRescheduleTimeout 在重排心跳时没能等正在跑的那一轮排空时返回(值已生效于
// 下一次调度判定,只是当前那次等待没有被作废)。
var ErrTidyRescheduleTimeout = errors.New("memory/tidy: heartbeat reschedule timed out")
// ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。
//
// 返回 nil 表示**值已生效、心跳已按新周期重排**;返回错误时**值同样已生效**,只是
// 心跳这次没排成(错误文案里说清是哪一种),调用方(网关)据此如实回 `hot:false`。
func (s *TidyService) ApplyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning) error {
next := tidyPanelTuning(base, panel, s.log)
// 只把 runMu 拿住 —— 它同时排除「正在跑的一轮」与「刚要开跑的一轮」。注意
// 顺序:**先拿 runMu 再 Stop**。反过来的话,心跳若正卡在 runMu 上等开跑,Stop
// 会等一个永远等不到退出的 goroutine(它被我抱着锁挡在门外)。
s.runMu.Lock()
defer s.runMu.Unlock()
s.mu.Lock()
unchanged := s.cfg == next
s.mu.Unlock()
if unchanged {
// 面板这次只改了别的键(如 ingestGate):处理配置根本没变 ⇒ 不打扰心跳。
s.applyTidyConfigLocked(next)
return nil
}
reschedErr := s.rescheduleTidyHeartbeat()
s.applyTidyConfigLocked(next)
return reschedErr
}
// tidyPanelTuning 是「面板 > `.env` > 代码默认」的**重算点**(口径 1)。
//
// 它与组合根 `wireMemoryTidy` 里的那一段是**同一条链**(base → autoApply 两个键 →
// ApplyTidyScheduleTuning → normalizeTidyConfig),只是可以随时再跑一次。刻意做成
// 一个函数而不是把两处各写一遍逻辑,是为了让「优先级链」只有一份可读的写法:
// 将来把组合根那一段改成调用它,两处就彻底合一(组合根那个文件不在本切片 scope 内,
// 因此这里先留一个等价的实现,并已在回报里登记)。
func tidyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning, log *slog.Logger) TidyConfig {
cfg := base
cfg.DisableAutoApply = panel.AutoApplyOff()
if panel.AutoApplyPerRound > 0 {
cfg.AutoApplyPerRound = panel.AutoApplyPerRound
}
cfg = ApplyTidyScheduleTuning(cfg, panel, log)
return normalizeTidyConfig(cfg)
}
// applyTidyConfigLocked 把新配置落到**两个**持有者上(调用方必须已持 runMu,并已让
// 心跳停下):
// - `s.cfg` 是调度判据(`intervalAt` / `inQuietHours`)的来源;
// - `s.tidier.cfg` 是每一轮实际用的阈值/上限/自动档开关的来源。
//
// 两处都要写:只写前者 ⇒ 轮次仍用旧阈值;只写后者 ⇒ 调度仍按旧周期。
func (s *TidyService) applyTidyConfigLocked(cfg TidyConfig) {
s.mu.Lock()
s.cfg = cfg
s.mu.Unlock()
s.tidier.cfg = cfg
}
// rescheduleTidyHeartbeat 把当前那次等待作废,并按**新配置**重新排一次心跳。
//
// 未在跑(未 Start / 已 Stop)时是空操作。排空超时时**仍把心跳挂回去**:`Stop` 已经
// 把 running 置为 false,不重启就等于让定时整理**悄悄停摆**(本项目明令禁止的静默
// 失效形态);旧的那只 goroutine 会在当前这一轮跑完后自行退出。
//
// 重排用的父 context 是 Background:本对象没有保存父 ctx 的字段(那在
// tidy_service.go 里,不在本切片 scope 内),而关闭路径本来就由组合根显式 `Stop`
// 收尾(见 wire_services.go),因此不依赖父 ctx 取消。
func (s *TidyService) rescheduleTidyHeartbeat() error {
s.mu.Lock()
running := s.running
s.mu.Unlock()
if !running {
return nil
}
stopCtx, cancel := context.WithTimeout(context.Background(), memoryTidyRescheduleTimeout)
defer cancel()
if err := s.Stop(stopCtx); err != nil {
s.log.Warn("memory tidy tuning applied, but heartbeat reschedule timed out",
"timeout", memoryTidyRescheduleTimeout.String(), "error", err)
s.Start(context.Background())
return fmt.Errorf("%w: %v", ErrTidyRescheduleTimeout, err)
}
s.Start(context.Background())
return nil
}
@@ -0,0 +1,213 @@
package memory
import (
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件锁定**运行期调优**(切片 MEMPANEL / M4:面板保存 ⇒ 当场生效,
// 不要求重启后端)的两条判据:
// ① 整理档:面板值按「面板 > `.env` > 代码默认」**重算**(不是叠在现值上)——
// 把键清掉(nil)必须回到 `base` 那一档,而不是留着上一次的面板值;心跳
// 在跑时保存会被**重排**(新周期立刻生效),没跑时是空操作。
// ② 入库审查档:`ingestGate` / `tempTtlSec` 两个键同样重算(中档结构性为空 ⇒
// 清掉即回代码默认),且经 `Store.ApplyIngestPanelTuning` 转调可达。
// tuningInt 返回面板调优用的 *int。
func tuningInt(v int) *int { return &v }
// tuningBool 返回面板调优用的 *bool。
func tuningBool(v bool) *bool { return &v }
// TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase 面板 > .env > 代码默认,
// 且**重算而不是叠加**:清掉一个键就回到 base 那一档。
func TestTidyServiceApplyPanelTuning_RecomputesFromEnvBase(t *testing.T) {
t.Parallel()
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, time.Now, nil)
// 面板给了三个键 ⇒ 三个都按面板走,没给的键保持 base(.env)档。
panel := runtimeconfig.MemoryTidyTuning{
IntervalSec: tuningInt(10800),
QuietIntervalSec: tuningInt(36000),
AutoApply: tuningBool(false),
}
if err := svc.ApplyPanelTuning(base, panel); err != nil {
t.Fatalf("ApplyPanelTuning: %v", err)
}
if svc.cfg.IntervalSec != 10800 || svc.cfg.Interval != 3*time.Hour {
t.Fatalf("interval = %d / %s, want 10800 / 3h", svc.cfg.IntervalSec, svc.cfg.Interval)
}
if svc.cfg.QuietIntervalSec != 36000 || svc.cfg.QuietInterval != 10*time.Hour {
t.Fatalf("quiet interval = %d / %s, want 36000 / 10h",
svc.cfg.QuietIntervalSec, svc.cfg.QuietInterval)
}
if !svc.cfg.DisableAutoApply {
t.Fatal("autoApply:false 应当落到 DisableAutoApply")
}
if svc.cfg.QuietStartHour != 23 || svc.cfg.QuietEndHour != 7 {
t.Fatalf("没写 quietStartHour/quietEndHour 时应当保持 base:%+v", svc.cfg)
}
// 两个持有者都要写:调度读 s.cfg,每一轮读 tidier.cfg。
if svc.tidier.cfg.IntervalSec != 10800 || !svc.tidier.cfg.DisableAutoApply {
t.Fatalf("tidier 配置没跟上:%+v", svc.tidier.cfg)
}
// 🔴 清掉 intervalSec(模拟面板 null 清除)⇒ 必须回到 base 的 86400,
// 而不是留在上一次的 10800。
cleared := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(1), QuietEndHour: tuningInt(5)}
if err := svc.ApplyPanelTuning(base, cleared); err != nil {
t.Fatalf("ApplyPanelTuning(cleared): %v", err)
}
if svc.cfg.IntervalSec != 86400 {
t.Fatalf("清掉面板键后应回到 .env 档 86400,得到 %d(叠加而非重算)", svc.cfg.IntervalSec)
}
if svc.cfg.QuietIntervalSec != 86400 {
t.Fatalf("清掉 quietIntervalSec 后应回到 intervalSec 档,得到 %d", svc.cfg.QuietIntervalSec)
}
if svc.cfg.DisableAutoApply {
t.Fatal("清掉 autoApply 后应回到代码默认(开)")
}
if svc.cfg.QuietStartHour != 1 || svc.cfg.QuietEndHour != 5 {
t.Fatalf("quietStartHour/quietEndHour 应当采用面板值:%+v", svc.cfg)
}
}
// TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat 心跳在跑时保存 ⇒ 心跳
// 被重排(仍活着,且下一次等待按**新**周期算)。没跑时是空操作。
func TestTidyServiceApplyPanelTuning_ReschedulesRunningHeartbeat(t *testing.T) {
t.Parallel()
at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local)
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil)
// 未启动:保存只改配置,不碰心跳状态(用一个不含 intervalSec 的补丁,免得
// 影响下面「旧周期」的断言)。
idlePanel := runtimeconfig.MemoryTidyTuning{QuietStartHour: tuningInt(22), QuietEndHour: tuningInt(6)}
if err := svc.ApplyPanelTuning(base, idlePanel); err != nil {
t.Fatalf("ApplyPanelTuning(idle): %v", err)
}
svc.mu.Lock()
running := svc.running
svc.mu.Unlock()
if running {
t.Fatal("未 Start 时不该因为保存而启动心跳")
}
// 启动后:造出「上次跑过」的历史,于是下一拍 = 上次 + 当时时段的周期。
// 15:00、上次 14:30、周期 24h ⇒ 下一拍还要等 23.5 小时(旧周期)。
svc.Start(nil)
t.Cleanup(func() { _ = svc.Stop(nil) })
svc.mu.Lock()
svc.lastRun = at.Add(-30 * time.Minute)
svc.mu.Unlock()
if got := svc.nextDelay(); got != 23*time.Hour+30*time.Minute {
t.Fatalf("启动后 nextDelay = %s, want 23h30m(旧周期)", got)
}
// 把周期改短并**重排**:新的下一拍应当按 1 小时的周期算(= 上次 + 1h ⇒ 还有
// 30 分钟),而不是继续睡原来那 23.5 小时。
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil {
t.Fatalf("ApplyPanelTuning(running): %v", err)
}
svc.mu.Lock()
running = svc.running
svc.mu.Unlock()
if !running {
t.Fatal("重排后心跳必须仍在跑(否则定时整理会悄悄停摆)")
}
if got := svc.nextDelay(); got != 30*time.Minute {
t.Fatalf("重排后的 nextDelay = %s, want 30m(仍按旧周期说明没重排)", got)
}
}
// TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat 只改别的键(配置没变)
// 时不打扰心跳:那一次保存不该白白停一次心跳。
func TestTidyServiceApplyPanelTuning_UnchangedConfigKeepsHeartbeat(t *testing.T) {
t.Parallel()
at := time.Date(2026, 10, 13, 15, 0, 0, 0, time.Local)
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, fixedTidyClock(at), nil)
svc.Start(nil)
t.Cleanup(func() { _ = svc.Stop(nil) })
same := normalizeTidyConfig(base)
if svc.cfg != same {
t.Fatalf("前置条件不成立:%+v vs %+v", svc.cfg, same)
}
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{}); err != nil {
t.Fatalf("ApplyPanelTuning(noop): %v", err)
}
if got := svc.nextDelay(); got != 8*time.Hour {
t.Fatalf("配置没变时不该重排心跳(无历史时仍锚到低峰起点 8h):nextDelay = %s", got)
}
}
// TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys 审查侧的两个键同样
// **重算**:清掉即回代码默认(中档结构性为空)。
func TestPreInsertReviewerApplyPanelTuning_RecomputesIngestKeys(t *testing.T) {
t.Parallel()
store := newTestStore(t)
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
// 面板关闸 + 1 小时 TTL。
panel := runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false), TempTtlSec: tuningInt(3600)}
if err := reviewer.ApplyPanelTuning(panel); err != nil {
t.Fatalf("ApplyPanelTuning: %v", err)
}
if !reviewer.cfg.DisableIngestGate {
t.Fatal("ingestGate:false 应当当场关掉同步入库闸")
}
if reviewer.cfg.TempTTL != time.Hour {
t.Fatalf("tempTtlSec 未生效:%s", reviewer.cfg.TempTTL)
}
// 闸的判定面也真的变了(gateEnabled 直接读这份 cfg)。
if reviewer.gateEnabled() {
t.Fatal("关闸后 gateEnabled 仍为 true")
}
// 清掉两个键 ⇒ 回到代码默认(闸开、TTL 7 天)。
if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{}); err != nil {
t.Fatalf("ApplyPanelTuning(cleared): %v", err)
}
if reviewer.cfg.DisableIngestGate || !reviewer.gateEnabled() {
t.Fatal("清掉 ingestGate 后应当回到默认(开)")
}
if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want {
t.Fatalf("清掉 tempTtlSec 后 TTL = %s, want %s(代码默认)", reviewer.cfg.TempTTL, want)
}
// 坏值(0 秒)不得落进 cfg:回落代码默认,而不是「不过期」也不是「落库即过期」。
if err := reviewer.ApplyPanelTuning(runtimeconfig.MemoryTidyTuning{TempTtlSec: tuningInt(0)}); err != nil {
t.Fatalf("ApplyPanelTuning(bad ttl): %v", err)
}
if want := time.Duration(tempTtlSec) * time.Second; reviewer.cfg.TempTTL != want {
t.Fatalf("坏 TTL 应当回落默认 %s,得到 %s", want, reviewer.cfg.TempTTL)
}
// 审查本身的其它旋钮**一个都不动**(它们不是面板键)。
if !reviewer.cfg.Enabled || reviewer.cfg.QueueSize == 0 || reviewer.cfg.ScanLimit == 0 {
t.Fatalf("面板调优误伤了别的旋钮:%+v", reviewer.cfg)
}
}
// TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer 网关只能通过记忆存储
// 触达审查侧(deps.Memories),因此这条转调就是那条通道:接了审查 ⇒ 转调生效;
// 没接(审查关闭)⇒ 空操作 + nil(那时两个键本来就不生效)。
func TestStoreApplyIngestPanelTuning_DelegatesToAttachedReviewer(t *testing.T) {
t.Parallel()
store := newTestStore(t)
if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil {
t.Fatalf("未接审查时应当空操作 + nil,得到 %v", err)
}
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
store.AttachIngestReviewer(reviewer)
if err := store.ApplyIngestPanelTuning(runtimeconfig.MemoryTidyTuning{IngestGate: tuningBool(false)}); err != nil {
t.Fatalf("Store.ApplyIngestPanelTuning: %v", err)
}
if !reviewer.cfg.DisableIngestGate {
t.Fatal("经 Store 的转调没有落到审查服务上")
}
}
@@ -0,0 +1,331 @@
package gateway
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件承载记忆整理「面板调优」的**管理读写端点**(契约待塔登记,见回报):
//
// GET /api/v1/admin/memories/tidy/tuning 读:盘上的 7 键值 + 取值规则
// PUT /api/v1/admin/memories/tidy/tuning 写:三态补丁 + 原子落盘 + 热生效
//
// 它回答的是主人的两句原话(逐字,见 M4 mission Context):
//
// 「`.env` 只做兜底用 在除了第1次部署之后 后端自动创建 独立的面板配置文件之后就走
// 面板配置文件。这些键优先从面板配置上取」
// 「频率必须调 可以调 1 小时或者三小时 或者动态分配,比如白天是间隔短一点 晚上到了
// 静默期稍微长一点」
//
// 此前 `$DATA_DIR/config/memory-tidy.json` **只有读路径**(`cmd/cyrene/wire_memory_tidy.go`
// 在装配期读一次 + 启动播种):主人要改频率只能 SSH 改文件 + 重启后端。本切片把
// 「读回来 → 改 → 落盘」这条面板路径补上,**零新 env 键**(`.env` 已冻结,
// design/13 §2.1)、**不新开文件家族**。
//
// 四条不可走样的口径:
//
// 1. **三态**(硬要求,与契约里 `plugin.saveProvider` 同族语义):键**缺位** ⇒ 不动;
// **合法值** ⇒ 采用;**非法值** ⇒ `400 invalid_payload` 且**盘上一个字节都不动**。
// `null` 是这一族的清除哨兵(= 删掉该键、回到下一档兜底)。三态的实现与
// 「非法值不落盘」的证据在 `admin_memory_tuning_file.go` 的
// `parseMemoryTidyTuningPatch`(解析阶段就拒,落盘只在解析成功之后发生)。
// 2. **写端点带审计**(动作 `memory.tidy.tuning`),且**不改** `.env`、不重启后端。
// 3. **热生效**:保存后把新值当场叠到运行期(可选能力,见 MemoryTidyTuningApplier)。
// 能力不存在或失败时,响应 `hot:false` + `hotError`,面板**如实**说「已落盘、
// 要重启后端」——绝不把「落盘了」说成「生效了」。
// 4. **诚实回话**:写成功后的响应是**重新读盘**的结果(真值回显),不是「我以为
// 写成了什么」;盘上文件解不开时读/写**都**拒绝(`409 tuning_corrupt`)并说明
// 运行期此刻正回落 `.env`/代码默认。
//
// 权限:GET 与 PUT 都走 **writeAuth(owner|admin)**(与本族其它记忆端点
// `/admin/memories`、`POST /admin/memories/{id}/revert` 同档)——它是私有记忆面的
// 运行期旋钮,不是给 viewer 的监控投影。
// memoryTuningLogSource 是本子功能的日志来源(模块/子功能可追溯,AGENTS §10)。
const memoryTuningLogSource = "gateway/memory-tuning"
// memoryTuningAuditAction 是写端点落审计的动作名(与 memory.tidy.apply /
// memory.tidy.reject 同一族命名)。
const memoryTuningAuditAction = "memory.tidy.tuning"
// memoryTuningMaxBodyBytes 是写端点请求体的墙钟上限。这个文件是给人改的小配置
// (11 个标量键),正常请求体不到 200 字节;给上限只为让畸形/超大请求不占内存,
// 不是业务上限。
const memoryTuningMaxBodyBytes = 16 << 10
// MemoryTidyTuningApplier 是「把面板值当场叠到**整理自动档**」的可选能力。
//
// 它**不是**必需依赖:组合根注入的整理服务(`deps.MemoryTidy` = `*memory.TidyService`)
// 实现了它 ⇒ 保存后立刻生效;没实现 ⇒ 值照旧落盘、但要等后端重启,响应里
// `hot:false` 如实说出来(口径 3)。
//
// base 是**`.env` 层**(由 `memory.TidyConfigFromConfig` 从进程生效配置投影而来,
// 与 wireMemoryTidy 用的是同一个唯一实现点);panel 是刚落盘的那一份。实现必须按
// 「面板 > `.env` > 代码默认」逐键重算,且**缺位键保持 base 的值** —— 也就是
// wireMemoryTidy 当初那一遍的同一口径,只是可以随时再跑一次。
type MemoryTidyTuningApplier interface {
ApplyPanelTuning(base memory.TidyConfig, panel runtimeconfig.MemoryTidyTuning) error
}
// MemoryIngestTuningApplier 同理,服务于**同步入库闸 / 入库前审查**一侧
// (`ingestGate` / `tempTtlSec`)——它的持有者是记忆存储,因此由 `deps.Memories`
// 那个出口解析(网关不为此新开依赖面)。
type MemoryIngestTuningApplier interface {
ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error
}
// AdminMemoryTidyTuningView 是 GET / PUT `/admin/memories/tidy/tuning` 的 data。
type AdminMemoryTidyTuningView struct {
// Path 是面板配置文件的落点(**相对 `$DATA_DIR`**,如 `config/memory-tidy.json`):
// 面板据此如实告诉主人改的是哪个文件,而不必把部署绝对路径下发。
Path string `json:"path"`
// Present 报告那份文件是否已存在且有内容。false = 还没播种(后端**下次启动**
// 会自动按当时生效值创建它,见 runtimeconfig.SeedMemoryTidy)。
Present bool `json:"present"`
// Hot 报告这次结果是否**当场生效**(true)。false = 已落盘,但要等后端重启 ——
// 面板必须照实说,不得显示成「已生效」。GET 里恒为 false:只读不改变任何东西,
// 「生效」这个问题在只读路径上没有意义。
Hot bool `json:"hot"`
// HotError 在「已保存、但没能在运行期立刻生效」时给出原因(`hot:false` 时才有)。
HotError string `json:"hotError,omitempty"`
// Specs 是这 7 个键的**取值规则**(类型/单位/上下界/兜底来源),随读下发 ⇒
// 面板的表单与就地校验都由它驱动,不会出现「面板允许、后端 400」的两套规则。
Specs []memoryTidyTuningSpec `json:"specs"`
// Values 是**盘上**的值:键缺位 ⇒ 整个键不出现(= 跟随兜底),不编一个 0 ——
// 「写了 0」(坏值,运行期会 WARN 回落)与「没写」在面板上必须看得出区别。
Values map[string]any `json:"values"`
// EnvValues 是缺位时**下一档(`.env`)**的进程生效值(只读下发):面板据此写
// 「留空 ⇒ 跟随 `.env` 的 86400」,而不必自己猜一个数。与
// GET /admin/config-facts 同源(都取 `s.cfg`,网关绝不直接 fopen `.env`)。
// 某个键**连 `.env` 档也没有值**(未填 / 结构性无此键)时它不出现。
EnvValues map[string]int `json:"envValues,omitempty"`
}
// adminMemoryTidyTuningRead 是 GET 的处理函数:只读、不审计(与
// /admin/config-facts、tidy 试算同一档:读事实不改状态)。
func (s *Server) adminMemoryTidyTuningRead(w http.ResponseWriter, _ *http.Request) {
path, ok := s.memoryTidyTuningPath()
if !ok {
writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_unavailable", "数据目录未接线,面板配置文件落点无法确定"))
return
}
view, err := s.buildMemoryTidyTuningView(path)
if err != nil {
s.writeTuningCorrupt(w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
// adminMemoryTidyTuningWrite 是 PUT 的处理函数:三态补丁 → 原子落盘 → 热生效 →
// 审计。顺序是刻意的:**校验全部发生在落盘之前**(口径 1),落盘之后才谈生效。
func (s *Server) adminMemoryTidyTuningWrite(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, memoryTuningMaxBodyBytes))
if err != nil {
s.auditClaims(r, memoryTuningAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "读取请求体失败"))
return
}
patch, err := parseMemoryTidyTuningPatch(body)
if err != nil {
// 非法值/未知键:**盘上一个字节都不动**(解析阶段就退出,写盘在下面)。
s.auditClaims(r, memoryTuningAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_payload", "%v", err))
return
}
path, ok := s.memoryTidyTuningPath()
if !ok {
s.auditClaims(r, memoryTuningAuditAction, "error")
writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_unavailable", "数据目录未接线,面板配置文件落点无法确定"))
return
}
// 落盘(补丁 + 原子写)交给 runtimeconfig 家族:它同时是「盘上那份能不能被
// 运行期读走」的**唯一**判定点(读不走的文件拒绝覆盖,见 ErrInvalidMemoryTidy)。
if !patch.empty() {
if err := s.commitMemoryTidyTuningPatch(path, patch); err != nil {
s.auditClaims(r, memoryTuningAuditAction, "error")
s.writeTuningWriteFailure(w, err)
return
}
}
view, err := s.buildMemoryTidyTuningView(path)
if err != nil {
// 刚落盘就解不开:这是本端点的编程/磁盘错误,如实回 500,绝不回一个
// 「已保存」的假成功。
s.auditClaims(r, memoryTuningAuditAction, "error")
s.writeTuningCorrupt(w, err)
return
}
hot, hotErr := s.applyMemoryTidyTuningHot(path)
view.Hot = hot
if hotErr != nil {
view.HotError = hotErr.Error()
}
s.auditClaims(r, memoryTuningAuditAction, "ok")
writeJSON(w, http.StatusOK, view)
}
// commitMemoryTidyTuningPatch 把补丁交给 runtimeconfig 家族原子落盘。值一个都没变
// 时不落盘(「保存同样的值」不该在盘上留一次无意义的改写)。
func (s *Server) commitMemoryTidyTuningPatch(path string, patch memoryTidyTuningPatch) error {
_, wrote, err := runtimeconfig.PatchMemoryTidyTuning(path, patch.set, patch.clear)
if err != nil {
return err
}
log := logger.WithSource(s.log, memoryTuningLogSource)
if !wrote {
log.Info("memory tidy tuning unchanged (no bytes written)", "path", path, "keys", patch.changed)
return nil
}
log.Info("memory tidy tuning saved", "path", path, "keys", patch.changed)
return nil
}
// buildMemoryTidyTuningView 读盘并组装面板视图(口径 4:响应就是**盘上真值**)。
func (s *Server) buildMemoryTidyTuningView(path string) (AdminMemoryTidyTuningView, error) {
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
return AdminMemoryTidyTuningView{}, err
}
present, err := memoryTidyTuningPresent(path)
if err != nil {
return AdminMemoryTidyTuningView{}, err
}
return AdminMemoryTidyTuningView{
Path: runtimeconfig.MemoryTidyConfigRelPath,
Present: present,
Specs: memoryTidyTuningSpecs(),
Values: memoryTidyTuningValues(tuning),
EnvValues: s.memoryTidyTuningEnvValues(),
}, nil
}
// memoryTidyTuningPresent 报告面板配置文件是否已存在且**有内容**(全空白 = 还没配过,
// 与 runtimeconfig.LoadMemoryTidyTuning 的口径一致)。
func memoryTidyTuningPresent(path string) (bool, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("读取 %s:%w", path, err)
}
return len(strings.TrimSpace(string(raw))) > 0, nil
}
// memoryTidyTuningEnvValues 返回缺位时的**下一档(`.env`)**进程生效值。
//
// 只取 `s.cfg`(配置层已消化「进程环境 → `.env` → 声明默认」):网关**绝不**
// 直接 fopen `.env`,否则面板看到的是文件原文而不是运行实例在用的数。
//
// 三处刻意省略(省略 = 「这一档也没值,继续落到代码默认」,而不是编一个 0):
// - `MEMORY_TIDY_INTERVAL_SEC` ≤ 0:非正数是坏值(运行期会 WARN 并回落),
// 报出来会让人以为「留空就得到 0 秒」;
// - 安静时段两端**都为 0**:0/0 是「未填」,运行期会回落到设计默认窗口,
// 此时报 0/0 等于把「未填」谎报成一个午夜窗口。
//
// 其余四个键(`quietIntervalSec` / `ingestGate` / `autoApply` / `tempTtlSec`)在
// `.env` 里**结构性没有出处**(`.env` 已冻结,design/13 §2.1),因此不存在第二档,
// 它们的下一档就是代码默认(specs 里的 fallback 已说明)。
func (s *Server) memoryTidyTuningEnvValues() map[string]int {
out := map[string]int{}
if s.cfg == nil {
return out
}
if v := s.cfg.MemoryTidy.IntervalSec; v > 0 {
out["intervalSec"] = v
}
if start, end := s.cfg.MemoryTidy.QuietStartHour, s.cfg.MemoryTidy.QuietEndHour; start != 0 || end != 0 {
out["quietStartHour"] = start
out["quietEndHour"] = end
}
return out
}
// memoryTidyTuningPath 解析面板配置文件的绝对落点(`$DATA_DIR/config/memory-tidy.json`)。
//
// 数据目录的来源与图片上传端点同一口径:优先已解析的 `deps.DataDir`,为空时回退
// `cfg.DataDir`;两者都空(未接线)时报 false,调用方如实回 `tuning_unavailable`,
// 绝不猜一个目录去写。
func (s *Server) memoryTidyTuningPath() (string, bool) {
dataDir := s.dataDir
if dataDir == "" && s.cfg != nil {
dataDir = s.cfg.DataDir
}
if dataDir == "" {
return "", false
}
return runtimeconfig.DefaultMemoryTidyPath(dataDir), true
}
// applyMemoryTidyTuningHot 把刚落盘的面板值当场叠到运行期(口径 3)。
//
// 返回 (hot, err):hot=false 表示这次**没能在运行期生效**(能力未接线,或叠的过程
// 报错),err 说明原因。两者只用于如实回话,**不影响**「已经落盘」这个事实 ——
// 值不会因为热生效失败而丢。
//
// 能力用类型断言探测(可选能力,与 `plugin` 侧的可选接口同一手法):网关**不为此**
// 新增 Deps 字段/Server 字段 —— 整理服务本来就在 `s.memoryTidy` 里,审查器挂在
// `deps.Memories` 的实现上。这样「有没有热生效」是一件可观测的事实,而不是一句
// 靠不住的断言。
func (s *Server) applyMemoryTidyTuningHot(path string) (hot bool, hotErr error) {
tuning, err := runtimeconfig.LoadMemoryTidyTuning(path)
if err != nil {
return false, fmt.Errorf("重读面板配置失败:%v", err)
}
applied := false
if applier, ok := s.memoryTidy.(MemoryTidyTuningApplier); ok {
base := memory.TidyConfig{}
if s.cfg != nil {
base = memory.TidyConfigFromConfig(s.cfg.MemoryTidy)
}
if err := applier.ApplyPanelTuning(base, tuning); err != nil {
return false, fmt.Errorf("整理自动档未能热生效:%v", err)
}
applied = true
}
if applier, ok := s.memories.(MemoryIngestTuningApplier); ok {
if err := applier.ApplyIngestPanelTuning(tuning); err != nil {
return false, fmt.Errorf("入库审查未能热生效:%v", err)
}
applied = true
}
if !applied {
return false, fmt.Errorf("运行期没有接线可热生效的实现(deps.MemoryTidy / deps.Memories);新值已落盘,重启后端后生效")
}
return true, nil
}
// writeTuningCorrupt 把「盘上那份读不走」映射成 409 tuning_corrupt,并把**运行期的
// 实际后果**写进文案:此刻它正整体回落 `.env`/代码默认(见 runtimeconfig 的读取口径),
// 而面板**拒绝**在这个文件上覆盖 —— 坏文件请人工修复或删除。
func (s *Server) writeTuningCorrupt(w http.ResponseWriter, cause error) {
logger.WithSource(s.log, memoryTuningLogSource).Warn(
"memory tidy tuning file unusable", "path", runtimeconfig.MemoryTidyConfigRelPath, "error", cause)
writeError(w, apperr.Wrap(apperr.ErrConflict, "tuning_corrupt",
"面板配置文件 %s 解不开(运行期正回落 .env / 代码默认);面板拒绝在一个读不走的文件上覆盖,请人工修复或删除它:%v",
runtimeconfig.MemoryTidyConfigRelPath, cause))
}
// writeTuningWriteFailure 把落盘失败如实分类:**盘上那份读不走**(ErrInvalidMemoryTidy)
// 是 409(拒绝覆盖,不是故障),其余(IO/权限/重读不一致)是 500。两种都带出处,
// 绝不回一个「已保存」的假成功。
func (s *Server) writeTuningWriteFailure(w http.ResponseWriter, cause error) {
if errors.Is(cause, runtimeconfig.ErrInvalidMemoryTidy) {
s.writeTuningCorrupt(w, cause)
return
}
logger.WithSource(s.log, memoryTuningLogSource).Error(
"memory tidy tuning write failed", "path", runtimeconfig.MemoryTidyConfigRelPath, "error", cause)
writeError(w, apperr.Wrap(apperr.ErrInternal, "tuning_write_failed",
"面板配置文件 %s 落盘失败(盘上内容未改动):%v", runtimeconfig.MemoryTidyConfigRelPath, cause))
}
@@ -0,0 +1,275 @@
package gateway
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
)
// 本文件是记忆整理「面板调优」的**键规格与三态校验**层(文件读写与原子落盘在
// `internal/config/runtimeconfig` 家族里,见 memorytidy_save.go —— 落点是那一族的
// 职责,网关只负责 API 契约这一侧的取值规则)。
//
// 它管的是 `$DATA_DIR/config/memory-tidy.json` 里主人能调的 7 个旋钮(design/13 §2.1:
// 调优/门槛/上限/开关类参数走 `$DATA_DIR/config/**` + 代码默认值,不进 `.env`)。
//
// 三态与清除哨兵(与契约里 `plugin.saveProvider` 同族语义):
// - 键**没出现** ⇒ 不动它(`changed` 里也不出现);
// - 值为 `null` ⇒ 从面板文件里**删掉**这个键(回到下一档兜底:`.env` → 代码默认);
// - 值合法 ⇒ 采用;
// - 值非法(类型不对 / 超界 / 不是 JSON 对象 / 有不认识的键)⇒ 一律**在解析阶段**
// 被拒(调用方回 400 且盘上一个字节都不动),**绝不静默忽略** —— 静默忽略会让
// 主人以为改了而实际没改(把 `quietEndHour` 拼成 `quietEnd` 就正是这种形态)。
const (
// tuningKindInt / tuningKindBool 是本切片管得到的两种键型。
tuningKindInt = "int"
tuningKindBool = "bool"
// tuningUnitSec / tuningUnitHour 是 int 键的单位(面板据此写「秒 / 点」)。
tuningUnitSec = "sec"
tuningUnitHour = "hour"
// tuningSecMin 是三个秒键的下界:**必须为正**(≤0 的周期不是「没配」,而是一个
// 会让整理退化成空转的值;运行期口径同样把非正数当坏值回落,见
// memory.ApplyTidyScheduleTuning 与 runtimeconfig.MemoryTidyTuning 的注释)。
tuningSecMin = 1
// tuningHourMin / tuningHourMax 是钟点键的取值域(本地钟点 0..23)。
tuningHourMin = 0
tuningHourMax = 23
// tuningCodeDefault 是「缺位时落到代码默认」这一类兜底来源的标识(面板文案用)。
// 它**只说来源、不带数值**:数值的唯一真源在 memory 侧(normalizeTidyConfig /
// normalizeReviewConfig),网关不另抄一份默认值 —— 抄一份就是第二个真源。
tuningCodeDefault = "code-default"
)
// memoryTidyTuningSpec 是一个可写键的**取值规则**(唯一真源)。
//
// 它既驱动写侧校验,也随 GET 下发给面板(`specs`):面板据此渲染表单与就地报错,
// 因此不会出现「面板允许、后端 400」的两套规则(与 /admin/llm 下发
// reasoningKinds / personaProviderOptions 同一手法)。
type memoryTidyTuningSpec struct {
Key string `json:"key"`
// Kind ∈ int | bool;面板据此选输入控件类型。
Kind string `json:"kind"`
// Unit ∈ sec | hour(仅 int 键):面板据此写单位,不自己猜。
Unit string `json:"unit,omitempty"`
// Min / Max 是闭区间上下界(缺省 = 该端无约束)。
Min *int `json:"min,omitempty"`
Max *int `json:"max,omitempty"`
// Fallback 报告这个键**缺位(或面板 `null` 清除)时**生效值从哪来:
// - `env:MEMORY_TIDY_*` ⇒ 回落 `.env` 的同名键(进程生效值见
// GET /admin/config-facts 的同名事实行);
// - `panel:<key>` ⇒ 回落面板文件里另一个键;
// - `code-default` ⇒ 回落代码默认。
// 它只给**来源**,不给数值:数值的真源在各自那一档(见 tuningCodeDefault)。
Fallback string `json:"fallback"`
}
// memoryTidyTuningSpecs 是面板可写 7 键的**唯一清单**(顺序 = 面板显示顺序)。
//
// 为什么是这 7 个:主人要能调**整理频率**(白天短、静默期长)与**入库审查的两个
// 开关/时长**,而 `.env` 已冻结(design/13 §2.1)⇒ 这些旋钮只能落面板配置文件,
// 面板必须能**写**它。
//
// 刻意**不在**这里的两类键:
// - `t1Judge` / `t1PerWindow` / `t1WindowSec` / `autoApplyPerRound`:同文件的其它
// 旋钮,本切片的面板卡片不呈现,写端点也不接受它们(未知键一律 400,免得主人
// 以为改了、其实没动)。它们仍**原样保留**在文件里(补丁语义)。
func memoryTidyTuningSpecs() []memoryTidyTuningSpec {
secMin := tuningSecMin
hourMin := tuningHourMin
hourMax := tuningHourMax
return []memoryTidyTuningSpec{
{
Key: "intervalSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin,
Fallback: "env:MEMORY_TIDY_INTERVAL_SEC",
},
{
Key: "quietIntervalSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin,
Fallback: "panel:intervalSec",
},
{
Key: "quietStartHour", Kind: tuningKindInt, Unit: tuningUnitHour,
Min: &hourMin, Max: &hourMax, Fallback: "env:MEMORY_TIDY_QUIET_START_HOUR",
},
{
Key: "quietEndHour", Kind: tuningKindInt, Unit: tuningUnitHour,
Min: &hourMin, Max: &hourMax, Fallback: "env:MEMORY_TIDY_QUIET_END_HOUR",
},
{Key: "ingestGate", Kind: tuningKindBool, Fallback: tuningCodeDefault},
{Key: "autoApply", Kind: tuningKindBool, Fallback: tuningCodeDefault},
{Key: "tempTtlSec", Kind: tuningKindInt, Unit: tuningUnitSec, Min: &secMin, Fallback: tuningCodeDefault},
}
}
// memoryTidyTuningManagedKeys 返回可写键的键名(specs 顺序),供错误文案与审计用。
func memoryTidyTuningManagedKeys() []string {
specs := memoryTidyTuningSpecs()
out := make([]string, 0, len(specs))
for _, s := range specs {
out = append(out, s.Key)
}
return out
}
// memoryTidyTuningPatch 是一次写请求**已校验**的补丁。
//
// set / clear 是交给 runtimeconfig.PatchMemoryTidyTuning 的动作(clear = 删掉该键
// ⇒ 回到下一档兜底);changed 是本次真正出现的键(specs 顺序,含被清除的),空表示
// 一个键都没提交(此时**不写盘**,文件字节保持不变)。
type memoryTidyTuningPatch struct {
set map[string]json.RawMessage
clear []string
changed []string
}
// empty 报告这次请求没有提交任何受管的键。
func (p memoryTidyTuningPatch) empty() bool { return len(p.changed) == 0 }
// parseMemoryTidyTuningPatch 把请求体解析成一次已校验的补丁(三态的唯一实现点)。
//
// 未知键也归入「非法」:本端点**不静默忽略**任何提交上来的键 —— 静默忽略会让主人
// 以为改了而实际没改。
func parseMemoryTidyTuningPatch(body []byte) (memoryTidyTuningPatch, error) {
var raw map[string]json.RawMessage
if err := json.Unmarshal(body, &raw); err != nil {
return memoryTidyTuningPatch{}, fmt.Errorf("请求体必须是一个 JSON 对象:%w", err)
}
if raw == nil {
// JSON 的 `null` 解到 map 上是 nil 且**不报错**:它不是一个「提交了 0 个键」
// 的请求,而是一个形状不对的请求,如实拒掉(空补丁请用 `{}`)。
return memoryTidyTuningPatch{}, errors.New("请求体必须是一个 JSON 对象(收到 null)")
}
specs := memoryTidyTuningSpecs()
known := make(map[string]memoryTidyTuningSpec, len(specs))
for _, s := range specs {
known[s.Key] = s
}
if err := rejectUnknownTuningKeys(raw, known); err != nil {
return memoryTidyTuningPatch{}, err
}
patch := memoryTidyTuningPatch{set: map[string]json.RawMessage{}}
for _, s := range specs {
value, ok := raw[s.Key]
if !ok {
continue
}
patch.changed = append(patch.changed, s.Key)
if isJSONNull(value) {
patch.clear = append(patch.clear, s.Key)
continue
}
if err := validateTuningValue(s, value); err != nil {
return memoryTidyTuningPatch{}, err
}
patch.set[s.Key] = value
}
return patch, nil
}
// rejectUnknownTuningKeys 拒绝请求体里任何不受管的键(含拼错的键)。
func rejectUnknownTuningKeys(raw map[string]json.RawMessage, known map[string]memoryTidyTuningSpec) error {
unknown := make([]string, 0)
for k := range raw {
if _, ok := known[k]; !ok {
unknown = append(unknown, k)
}
}
if len(unknown) == 0 {
return nil
}
sort.Strings(unknown)
return fmt.Errorf("不认识的键:%s(本端点只写 %s;同文件其它旋钮请人工编辑)",
strings.Join(unknown, "、"), strings.Join(memoryTidyTuningManagedKeys(), "、"))
}
// validateTuningValue 校验一个键的值(类型 + 取值域)。错误文案直接回给面板显示,
// 因此带上**收到的原始值**,让主人一眼看出该改什么。
func validateTuningValue(spec memoryTidyTuningSpec, raw json.RawMessage) error {
switch spec.Kind {
case tuningKindBool:
var b bool
if err := json.Unmarshal(raw, &b); err != nil {
return fmt.Errorf("%s 必须是 true 或 false(收到 %s)", spec.Key, clipJSON(raw))
}
return nil
case tuningKindInt:
var n int
if err := json.Unmarshal(raw, &n); err != nil {
return fmt.Errorf("%s 必须是整数(收到 %s)", spec.Key, clipJSON(raw))
}
if spec.Min != nil && n < *spec.Min {
return fmt.Errorf("%s 必须 ≥ %d(%s),收到 %d", spec.Key, *spec.Min, tuningUnitLabel(spec), n)
}
if spec.Max != nil && n > *spec.Max {
return fmt.Errorf("%s 必须 ≤ %d(%s),收到 %d", spec.Key, *spec.Max, tuningUnitLabel(spec), n)
}
return nil
default:
// 规格表自己写错了型别(编程错误,不是客户端错):如实报出来,不猜一种校验。
return fmt.Errorf("%s 的键规格未定义(kind=%q)", spec.Key, spec.Kind)
}
}
// tuningUnitLabel 返回单位的中文说明(错误文案用)。
func tuningUnitLabel(spec memoryTidyTuningSpec) string {
switch spec.Unit {
case tuningUnitSec:
return "秒"
case tuningUnitHour:
return "本地钟点 0-23"
default:
return "整数"
}
}
// clipJSON 把收到的原始 JSON 片段截断成可安全回显的短串(错值可能很长)。
func clipJSON(raw json.RawMessage) string {
const maxClip = 40
s := strings.TrimSpace(string(raw))
if len(s) <= maxClip {
return s
}
return s[:maxClip] + "…"
}
// isJSONNull 报告这段原始值是不是 JSON 的 `null`(清除哨兵)。
func isJSONNull(raw json.RawMessage) bool {
return strings.TrimSpace(string(raw)) == "null"
}
// memoryTidyTuningValues 把盘上的调优结构投影成面板要的 7 键值表:**键缺位 ⇒ 整个
// 键不出现**(面板据此渲染「未设置、跟随兜底」),而不是回一个编出来的 0 ——
// 「0 秒」与「没配」在面板上必须看得出区别。
//
// 它刻意**不**回显同文件的其它键(t1Judge 等):那些不在本卡片的编辑范围,混进来
// 只会让人以为这张卡能改它们。
func memoryTidyTuningValues(t runtimeconfig.MemoryTidyTuning) map[string]any {
out := map[string]any{}
if t.IntervalSec != nil {
out["intervalSec"] = *t.IntervalSec
}
if t.QuietIntervalSec != nil {
out["quietIntervalSec"] = *t.QuietIntervalSec
}
if t.QuietStartHour != nil {
out["quietStartHour"] = *t.QuietStartHour
}
if t.QuietEndHour != nil {
out["quietEndHour"] = *t.QuietEndHour
}
if t.IngestGate != nil {
out["ingestGate"] = *t.IngestGate
}
if t.AutoApply != nil {
out["autoApply"] = *t.AutoApply
}
if t.TempTtlSec != nil {
out["tempTtlSec"] = *t.TempTtlSec
}
return out
}
@@ -0,0 +1,524 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// 本文件锁定记忆整理「面板调优」读写端点(`GET|PUT /api/v1/admin/memories/tidy/tuning`)
// 的判据:
// ① 读侧如实回显**盘上值**(键缺位 ⇒ 该键不出现)+ 取值规则 specs + `.env` 兜底值,
// 且不回显同文件里不受管的键;
// ② 写侧**三态**:键缺位 ⇒ 逐字节不动(含「不重写文件」);合法值 ⇒ 采用且保留
// 同文件其它键;非法值 ⇒ `400 invalid_payload` 且**盘上一个字节都不动**;
// ③ 盘上文件解不开 ⇒ 读/写都 `409 tuning_corrupt` 且绝不覆盖;
// ④ `null` 是清除哨兵(删键 ⇒ 回到下一档兜底);
// ⑤ RBAC 走 writeAuth(owner|admin)、写落审计 `memory.tidy.tuning`;
// ⑥ 热生效**如实**:接线了 ⇒ hot=true 且真把值交给运行期;没接线/失败 ⇒ hot=false +
// hotError,绝不把「落盘了」说成「生效了」。
// tuneEnvT 是一个注入了数据目录(= 面板配置文件落点所在)的网关测试环境。
type tuneEnvT struct {
srv *Server
ts *httptest.Server
dir string
jwt *auth.JWTService
}
// newTuneEnv 构造环境。`cfg.MemoryTidy` 被显式填上,用来验证读侧下发的
// 「缺位时 `.env` 那一档」确实取自进程生效配置(而不是网关自己编的数)。
func newTuneEnv(t *testing.T, deps Deps) *tuneEnvT {
t.Helper()
dir := t.TempDir()
cfg := cfgDefault()
cfg.MemoryTidy = config.MemoryTidyConfig{IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
deps.DataDir = dir
srv, ts, jwt := buildEnv(t, cfg, deps)
return &tuneEnvT{srv: srv, ts: ts, dir: dir, jwt: jwt}
}
func (e *tuneEnvT) url() string {
return e.ts.URL + "/api/v1/admin/memories/tidy/tuning"
}
func (e *tuneEnvT) path() string {
return filepath.Join(e.dir, filepath.FromSlash(runtimeconfig.MemoryTidyConfigRelPath))
}
func (e *tuneEnvT) token(t *testing.T, role auth.Role) string {
t.Helper()
return signAny(t, e.jwt, "acct-"+string(role), role)
}
// seed 写入一份面板配置并返回写后的字节(后续逐字节比对用)。
func (e *tuneEnvT) seed(t *testing.T, body string) []byte {
t.Helper()
if err := os.MkdirAll(filepath.Dir(e.path()), 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if err := os.WriteFile(e.path(), []byte(body), 0o600); err != nil {
t.Fatalf("seed: %v", err)
}
return e.bytes(t)
}
// bytes 读回盘上的原始字节:「非法值不落盘」的证据就是它与之前相等。
func (e *tuneEnvT) bytes(t *testing.T) []byte {
t.Helper()
raw, err := os.ReadFile(e.path())
if err != nil {
t.Fatalf("read tuning file: %v", err)
}
return raw
}
func (e *tuneEnvT) put(t *testing.T, token, raw string) (*http.Response, []byte) {
t.Helper()
return doRaw(t, http.MethodPut, e.url(), raw, token)
}
func (e *tuneEnvT) get(t *testing.T, token string) (*http.Response, []byte) {
t.Helper()
return doReq(t, http.MethodGet, e.url(), nil, token)
}
// view 解一次成功响应的 data。
func (e *tuneEnvT) view(t *testing.T, body []byte) AdminMemoryTidyTuningView {
t.Helper()
var view AdminMemoryTidyTuningView
if err := json.Unmarshal(decodeBody(t, body)["data"], &view); err != nil {
t.Fatalf("decode view: %v (body=%s)", err, body)
}
return view
}
// assertErrorCode 断言响应是错误信封且 code 匹配。
func assertErrorCode(t *testing.T, body []byte, want string) {
t.Helper()
var eobj struct {
Code string `json:"code"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
t.Fatalf("decode error: %v (body=%s)", err, body)
}
if eobj.Code != want {
t.Fatalf("error code = %q, want %q (body=%s)", eobj.Code, want, body)
}
}
// diskDoc 解出盘上的文档(键 → 原样 JSON 值)。
func (e *tuneEnvT) diskDoc(t *testing.T) map[string]json.RawMessage {
t.Helper()
doc := map[string]json.RawMessage{}
if err := json.Unmarshal(e.bytes(t), &doc); err != nil {
t.Fatalf("decode disk: %v", err)
}
return doc
}
// TestAdminMemoryTidyTuningRead_EchoesDiskValues 读侧:回显盘上 7 键(缺位不下发)、
// 下发取值规则与 `.env` 兜底值,且**不回显**同文件里不受管的键。
func TestAdminMemoryTidyTuningRead_EchoesDiskValues(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{
"intervalSec": 3600,
"quietStartHour": 23,
"quietEndHour": 7,
"ingestGate": false,
"t1PerWindow": 120
}`)
resp, body := env.get(t, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("read status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if view.Path != runtimeconfig.MemoryTidyConfigRelPath || !view.Present {
t.Fatalf("view path/present = %q/%v", view.Path, view.Present)
}
if got := view.Values["intervalSec"]; got != float64(3600) {
t.Fatalf("values.intervalSec = %#v, want 3600", got)
}
if got := view.Values["ingestGate"]; got != false {
t.Fatalf("values.ingestGate = %#v, want false", got)
}
// 键缺位 ⇒ 不出现(「没配」与「配成 0」必须分得开)。
for _, absent := range []string{"quietIntervalSec", "autoApply", "tempTtlSec"} {
if _, ok := view.Values[absent]; ok {
t.Fatalf("values 不该出现 %s(盘上没这个键):%#v", absent, view.Values)
}
}
// 不受管的键(t1PerWindow)不进 values:这张卡改不了它,混进来会误导。
if _, ok := view.Values["t1PerWindow"]; ok {
t.Fatalf("values 不该出现不受管的 t1PerWindow:%#v", view.Values)
}
if len(view.Specs) != len(memoryTidyTuningManagedKeys()) {
t.Fatalf("specs = %d 条, want %d", len(view.Specs), len(memoryTidyTuningManagedKeys()))
}
if view.EnvValues["intervalSec"] != 86400 || view.EnvValues["quietStartHour"] != 23 {
t.Fatalf("envValues = %#v, want intervalSec=86400 / quietStartHour=23", view.EnvValues)
}
if view.Hot {
t.Fatal("只读响应里 hot 必须为 false(读不改变任何东西)")
}
}
// TestAdminMemoryTidyTuningRead_MissingFileIsNotAnError 文件还没播种时:正常回 200、
// present=false、values 为空 —— 「还没配过」不是错误(与 runtimeconfig 的读取口径一致)。
func TestAdminMemoryTidyTuningRead_MissingFileIsNotAnError(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
resp, body := env.get(t, env.token(t, auth.RoleOwner))
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if view.Present || len(view.Values) != 0 {
t.Fatalf("view = %+v, want present=false / 无值", view)
}
}
// TestAdminMemoryTidyTuningRead_ViewerForbidden RBAC:viewer 403、匿名 401。
func TestAdminMemoryTidyTuningRead_ViewerForbidden(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{"intervalSec": 3600}`)
resp, body := env.get(t, env.token(t, auth.RoleViewer))
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("viewer read status = %d, want 403 (body=%s)", resp.StatusCode, body)
}
resp, _ = env.get(t, "")
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("anonymous read status = %d, want 401", resp.StatusCode)
}
}
// TestAdminMemoryTidyTuningWrite_AdoptsValidValuesAndKeepsOtherKeys 写侧「合法值 ⇒ 采用」:
// 7 键都能落盘,同文件里不受管的键**原样保留**(一次面板保存绝不抹掉别的旋钮)。
func TestAdminMemoryTidyTuningWrite_AdoptsValidValuesAndKeepsOtherKeys(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{"t1Judge": false, "t1PerWindow": 120}`)
// 用 admin 角色也走同一道门(writeAuth = owner|admin)。
resp, body := env.put(t, env.token(t, auth.RoleAdmin), `{
"intervalSec": 10800,
"quietIntervalSec": 3600,
"quietStartHour": 23,
"quietEndHour": 7,
"ingestGate": true,
"autoApply": true,
"tempTtlSec": 604800
}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("write status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
for key, want := range map[string]any{
"intervalSec": float64(10800), "quietIntervalSec": float64(3600),
"quietStartHour": float64(23), "quietEndHour": float64(7),
"ingestGate": true, "autoApply": true, "tempTtlSec": float64(604800),
} {
if got := view.Values[key]; got != want {
t.Fatalf("回显 values[%s] = %#v, want %#v", key, got, want)
}
}
doc := env.diskDoc(t)
if string(doc["intervalSec"]) != "10800" || string(doc["t1PerWindow"]) != "120" {
t.Fatalf("disk = %s", string(env.bytes(t)))
}
if _, ok := doc["t1Judge"]; !ok {
t.Fatalf("不受管的 t1Judge 被抹掉了:%s", string(env.bytes(t)))
}
// 未接运行期 ⇒ 如实说「已落盘、重启后生效」。
if view.Hot || view.HotError == "" {
t.Fatalf("未接热生效时 hot/hotError = %v/%q, want false/非空", view.Hot, view.HotError)
}
}
// TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched 三态之一:键**缺位** ⇒
// 不改动。空补丁连文件都不重写(mtime 都不动);只提交一个键时另一个键逐字节不动。
func TestAdminMemoryTidyTuningWrite_AbsentKeyLeavesBytesUntouched(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
before := env.seed(t, `{"intervalSec": 86400, "quietStartHour": 23}`)
past := time.Now().Add(-time.Hour)
if err := os.Chtimes(env.path(), past, past); err != nil {
t.Fatalf("chtimes: %v", err)
}
tok := env.token(t, auth.RoleOwner)
resp, body := env.put(t, tok, `{}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("empty patch status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if after := env.bytes(t); string(after) != string(before) {
t.Fatalf("空补丁改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
}
info, err := os.Stat(env.path())
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.ModTime().Equal(past) {
t.Fatalf("空补丁重写了文件(mtime %v ⇒ %v)", past, info.ModTime())
}
if resp, body := env.put(t, tok, `{"quietStartHour": 22}`); resp.StatusCode != http.StatusOK {
t.Fatalf("partial patch status = %d (body=%s)", resp.StatusCode, body)
}
doc := env.diskDoc(t)
if string(doc["intervalSec"]) != "86400" || string(doc["quietStartHour"]) != "22" {
t.Fatalf("partial patch 结果 = %s", string(env.bytes(t)))
}
}
// TestAdminMemoryTidyTuningWrite_InvalidValueNeverTouchesDisk 三态之三(本文件的核心
// 证据):任何非法输入都 `400 invalid_payload`,且**盘上一个字节都不动**。
func TestAdminMemoryTidyTuningWrite_InvalidValueNeverTouchesDisk(t *testing.T) {
t.Parallel()
cases := []struct{ name, body string }{
{"字符串冒充整数", `{"intervalSec": "3600"}`},
{"小数不是整数", `{"tempTtlSec": 1.5}`},
{"周期为 0(会让整理空转)", `{"intervalSec": 0}`},
{"周期为负", `{"quietIntervalSec": -60}`},
{"钟点越上界", `{"quietStartHour": 24}`},
{"钟点越下界", `{"quietEndHour": -1}`},
{"钟点不是数字", `{"quietEndHour": "07"}`},
{"开关不是布尔", `{"ingestGate": "yes"}`},
{"未知键(拼错的 quietEnd)", `{"quietEnd": 7}`},
{"请求体不是对象", `[1, 2]`},
{"请求体是 null", `null`},
{"请求体是空串", ``},
{"合法键与非法键同时提交", `{"intervalSec": 3600, "autoApply": "on"}`},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
env := newTuneEnv(t, Deps{})
before := env.seed(t, `{
"intervalSec": 86400,
"quietStartHour": 23,
"quietEndHour": 7,
"t1Judge": false
}`)
resp, body := env.put(t, env.token(t, auth.RoleOwner), tc.body)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
assertErrorCode(t, body, "invalid_payload")
if after := env.bytes(t); string(after) != string(before) {
t.Fatalf("非法输入改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
}
})
}
// 盘上**没有**文件时,非法输入也不得创建它(「先落盘再校验」同样是落盘)。
t.Run("文件不存在时也不创建", func(t *testing.T) {
env := newTuneEnv(t, Deps{})
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 0}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
if _, err := os.Stat(env.path()); !os.IsNotExist(err) {
t.Fatalf("非法输入创建了文件:err=%v", err)
}
})
}
// TestAdminMemoryTidyTuningWrite_NullClearsKey 清除哨兵:`null` 删掉该键(回到下一档
// 兜底),同文件其它键不受影响。
func TestAdminMemoryTidyTuningWrite_NullClearsKey(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{"intervalSec": 3600, "quietIntervalSec": 7200, "t1Judge": false}`)
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": null}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("clear status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if _, ok := view.Values["intervalSec"]; ok {
t.Fatalf("清除后不该再回显 intervalSec:%#v", view.Values)
}
if got := view.Values["quietIntervalSec"]; got != float64(7200) {
t.Fatalf("清除一个键影响了别的键:%#v", view.Values)
}
doc := env.diskDoc(t)
if _, ok := doc["intervalSec"]; ok {
t.Fatalf("盘上仍有 intervalSec:%s", string(env.bytes(t)))
}
if _, ok := doc["t1Judge"]; !ok {
t.Fatalf("清除一个键抹掉了别的键:%s", string(env.bytes(t)))
}
}
// TestAdminMemoryTidyTuningWrite_RefusesCorruptFile 盘上那份运行期读不走时,**读与写
// 都**拒绝(`409 tuning_corrupt`)且绝不覆盖 —— 否则面板会说「已保存」而运行期仍在
// 整体回落 `.env` 并记 WARN。
func TestAdminMemoryTidyTuningWrite_RefusesCorruptFile(t *testing.T) {
t.Parallel()
for _, tc := range []struct{ name, body string }{
{"类型写错", `{"intervalSec": "3600"}`},
{"根本不是 JSON", `not json at all`},
{"顶层不是对象", `[1, 2, 3]`},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
env := newTuneEnv(t, Deps{})
before := env.seed(t, tc.body)
tok := env.token(t, auth.RoleOwner)
resp, body := env.put(t, tok, `{"intervalSec": 3600}`)
if resp.StatusCode != http.StatusConflict {
t.Fatalf("write status = %d, want 409 (body=%s)", resp.StatusCode, body)
}
assertErrorCode(t, body, "tuning_corrupt")
if string(env.bytes(t)) != string(before) {
t.Fatalf("坏文件被覆盖了:%s", string(env.bytes(t)))
}
// 读侧同样如实报坏,而不是回一份空配置假装「没配过」。
rresp, rbody := env.get(t, tok)
if rresp.StatusCode != http.StatusConflict {
t.Fatalf("read status = %d, want 409 (body=%s)", rresp.StatusCode, rbody)
}
assertErrorCode(t, rbody, "tuning_corrupt")
})
}
}
// TestAdminMemoryTidyTuningWrite_SameValueDoesNotRewrite 「保存同样的值」不该在盘上留
// 一次无意义的改写(内容逐字节相同 ⇒ 不落盘、mtime 不动)。
func TestAdminMemoryTidyTuningWrite_SameValueDoesNotRewrite(t *testing.T) {
t.Parallel()
env := newTuneEnv(t, Deps{})
env.seed(t, `{"intervalSec": 3600}`)
past := time.Now().Add(-time.Hour)
if err := os.Chtimes(env.path(), past, past); err != nil {
t.Fatalf("chtimes: %v", err)
}
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 3600}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
info, err := os.Stat(env.path())
if err != nil {
t.Fatalf("stat: %v", err)
}
if !info.ModTime().Equal(past) {
t.Fatalf("同值保存重写了文件(mtime %v ⇒ %v)", past, info.ModTime())
}
if string(env.bytes(t)) != `{"intervalSec": 3600}` {
t.Fatalf("盘上内容变了:%s", string(env.bytes(t)))
}
}
// fakeTuningTidy 既是整理服务的只读出口,也是「可热生效」的实现(记录收到的值)。
type fakeTuningTidy struct {
fakeTidyDryRunner
gotBase *memory.TidyConfig
gotTuning *runtimeconfig.MemoryTidyTuning
err error
}
func (f *fakeTuningTidy) ApplyPanelTuning(base memory.TidyConfig, panel runtimeconfig.MemoryTidyTuning) error {
f.gotBase = &base
f.gotTuning = &panel
return f.err
}
// fakeTuningMemories 同时实现 MemoryStore 与「入库侧可热生效」。
type fakeTuningMemories struct {
got *runtimeconfig.MemoryTidyTuning
err error
}
func (f *fakeTuningMemories) List(context.Context) ([]memory.MemoryItem, error) {
return []memory.MemoryItem{}, nil
}
func (f *fakeTuningMemories) ApplyIngestPanelTuning(panel runtimeconfig.MemoryTidyTuning) error {
f.got = &panel
return f.err
}
// TestAdminMemoryTidyTuningWrite_HotAppliesAndAudits 接线了热生效实现时:保存后当场把
// **新值**交给运行期(hot=true),并落一条审计 `memory.tidy.tuning`/ok。
func TestAdminMemoryTidyTuningWrite_HotAppliesAndAudits(t *testing.T) {
t.Parallel()
tidy := &fakeTuningTidy{}
mems := &fakeTuningMemories{}
env := newTuneEnv(t, Deps{MemoryTidy: tidy, Memories: mems})
env.seed(t, `{"intervalSec": 86400}`)
resp, body := env.put(t, env.token(t, auth.RoleAdmin), `{"intervalSec": 10800, "tempTtlSec": 259200}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if !view.Hot || view.HotError != "" {
t.Fatalf("hot/hotError = %v/%q, want true/空", view.Hot, view.HotError)
}
if tidy.gotTuning == nil || tidy.gotTuning.IntervalSec == nil || *tidy.gotTuning.IntervalSec != 10800 {
t.Fatalf("整理档没收到面板值:%#v", tidy.gotTuning)
}
if mems.got == nil || mems.got.TempTtlSec == nil || *mems.got.TempTtlSec != 259200 {
t.Fatalf("入库侧没收到面板值:%#v", mems.got)
}
// base 是 `.env` 层(进程生效配置投影),不是面板值本身。
if tidy.gotBase == nil || tidy.gotBase.IntervalSec != 86400 {
t.Fatalf("base 不是 .env 层:%#v", tidy.gotBase)
}
recs, err := env.srv.obs.audit.List(context.Background(), 50)
if err != nil {
t.Fatalf("list audit: %v", err)
}
found := false
for _, rec := range recs {
if rec.What == memoryTuningAuditAction && rec.Result == "ok" {
found = true
}
}
if !found {
t.Fatalf("审计链里没有 %s/ok 的记录:%#v", memoryTuningAuditAction, recs)
}
}
// TestAdminMemoryTidyTuningWrite_HotFailureIsHonest 热生效实现报错时:**值照旧留在盘上**
// (不丢),但响应如实 `hot:false` + 原因,绝不说成「已生效」。
func TestAdminMemoryTidyTuningWrite_HotFailureIsHonest(t *testing.T) {
t.Parallel()
tidy := &fakeTuningTidy{err: errors.New("boom")}
env := newTuneEnv(t, Deps{MemoryTidy: tidy})
env.seed(t, `{"intervalSec": 86400}`)
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"intervalSec": 10800}`)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
view := env.view(t, body)
if view.Hot || view.HotError == "" {
t.Fatalf("hot/hotError = %v/%q, want false/非空", view.Hot, view.HotError)
}
if got := view.Values["intervalSec"]; got != float64(10800) {
t.Fatalf("热生效失败不该丢值:%#v", view.Values)
}
}
// TestAdminMemoryTidyTuning_UnavailableWithoutDataDir 数据目录未接线时如实报「没有落点」,
// 绝不猜一个目录去写(端点据此回 500 tuning_unavailable)。
func TestAdminMemoryTidyTuning_UnavailableWithoutDataDir(t *testing.T) {
t.Parallel()
srv, err := New(cfgDefault(), Deps{
Auth: auth.New(config.AuthConfig{AdminUser: "a", AdminPass: "b", JWTSecret: testSecret}),
})
if err != nil {
t.Fatalf("new server: %v", err)
}
if _, ok := srv.memoryTidyTuningPath(); ok {
t.Fatal("没有数据目录时不该解析出落点")
}
}
+10
View File
@@ -259,6 +259,16 @@ func (s *Server) registerAdminRoutes(
// 幂等;审计 `memory.tidy.reject`。见 admin_memory_tidy_reject.go。
mux.Handle("POST /api/v1/admin/memories/candidates/reject", writeAuth(http.HandlerFunc(s.adminTidyCandidatesReject)))
mux.Handle("POST /api/v1/admin/memories/tidy/run", writeAuth(http.HandlerFunc(s.adminMemoryTidyRun)))
// 记忆整理「面板调优」的读写(切片 MEMPANEL / M4;契约由塔登记):
// 读 .../memories/tidy/tuning 回显 `$DATA_DIR/config/memory-tidy.json` 里本卡片
// 管的 7 个旋钮 + 它们的取值规则(specs);写同一条路径是**三态补丁**
// (键缺位 = 不改动 / 合法值 = 采用 / 非法值 = 400 且盘上不动)+ 原子落盘
// + 审计(memory.tidy.tuning)。两条都走 writeAuth(owner|admin)—— 与同族的
// GET /admin/memories、POST /admin/memories/{id}/revert 同档:它是私有记忆面的
// 运行期旋钮,不是给 viewer 的监控投影;也**不是** owner-only(写的是面板配置
// 文件,不是身份/凭据面)。见 admin_memory_tuning.go。
mux.Handle("GET /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningRead)))
mux.Handle("PUT /api/v1/admin/memories/tidy/tuning", writeAuth(http.HandlerFunc(s.adminMemoryTidyTuningWrite)))
mux.Handle("GET /api/v1/admin/observability/audit", writeAuth(http.HandlerFunc(s.adminObservabilityAudit)))
mux.Handle("GET /api/v1/admin/sessions/{id}/export", writeAuth(http.HandlerFunc(s.adminExportSession)))
mux.Handle("POST /api/v1/admin/rag/query", writeAuth(http.HandlerFunc(s.adminSearchRag)))