Merge branch 'feat/mempanel-memory-tidy-tuning-panel-write' into dev

This commit is contained in:
AskaEth committed 2026-10-13 22:59:38 +08:00
commit 9ff0421888
24 files changed
+3351 -39

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
}
@@ -0,0 +1,151 @@
package runtimeconfig
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"runtime"
"testing"
)
// 本文件锁定 `memory-tidy.json` **写路径**(PatchMemoryTidyTuning)的四条口径:
// ① 补丁语义:只改提交的键,盘上其它键(t1Judge 等)**原样保留**;
// ② 值没变 ⇒ 一个字节都不写(连 mtime 都不动);
// ③ 盘上那份**读不走**时拒绝覆盖(ErrInvalidMemoryTidy),且**盘上不动**;
// ④ 原子写:temp + rename,成功之后不留 `.tmp`,文件权限 0600(Windows 不校验)。
// patchTuning 是测试用的补丁构造:键 → 原样 JSON 值。
func patchTuning(t *testing.T, pairs map[string]any) map[string]json.RawMessage {
t.Helper()
out := make(map[string]json.RawMessage, len(pairs))
for k, v := range pairs {
raw, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal %s: %v", k, err)
}
out[k] = raw
}
return out
}
// TestPatchMemoryTidyTuning_MergesAndKeepsOtherKeys 补丁只动提交的键,其余键原样保留;
// 返回值是**重新读盘**的结果(真值),不是「我以为写成了什么」。
func TestPatchMemoryTidyTuning_MergesAndKeepsOtherKeys(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"t1Judge": false, "t1PerWindow": 120, "intervalSec": 3600}`)
tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 7200}), nil)
if err != nil {
t.Fatalf("patch: %v", err)
}
if !wrote {
t.Fatal("值变了却没写盘")
}
if tuning.IntervalSec == nil || *tuning.IntervalSec != 7200 {
t.Fatalf("返回值不是读回的真值:%+v", tuning.IntervalSec)
}
if tuning.T1Judge == nil || *tuning.T1Judge {
t.Fatalf("没提交的 t1Judge 被动了:%+v", tuning.T1Judge)
}
doc := map[string]json.RawMessage{}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("decode: %v", err)
}
if string(doc["intervalSec"]) != "7200" || string(doc["t1PerWindow"]) != "120" {
t.Fatalf("盘上内容 = %s", raw)
}
if string(doc["t1Judge"]) != "false" {
t.Fatalf("不受管的 t1Judge 被抹掉了:%s", raw)
}
// 清除哨兵:clear 里的键从盘上消失(= 回到下一档兜底)。
if _, _, err := PatchMemoryTidyTuning(path, nil, []string{"intervalSec"}); err != nil {
t.Fatalf("clear: %v", err)
}
doc = map[string]json.RawMessage{}
raw, _ = os.ReadFile(path)
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatalf("decode: %v", err)
}
if _, ok := doc["intervalSec"]; ok {
t.Fatalf("clear 之后盘上仍有 intervalSec:%s", raw)
}
if _, ok := doc["t1Judge"]; !ok {
t.Fatalf("clear 抹掉了别的键:%s", raw)
}
}
// TestPatchMemoryTidyTuning_NoChangeWritesNothing 值没变 ⇒ 不写盘、不重排格式。
func TestPatchMemoryTidyTuning_NoChangeWritesNothing(t *testing.T) {
t.Parallel()
path := writeMemoryTidyFile(t, `{"intervalSec": 3600}`)
before, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read: %v", err)
}
tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 3600}), nil)
if err != nil {
t.Fatalf("patch: %v", err)
}
if wrote {
t.Fatal("值没变却写了盘")
}
after, _ := os.ReadFile(path)
if string(after) != string(before) {
t.Fatalf("值没变却改了盘上内容:\nbefore=%s\nafter=%s", before, after)
}
if tuning.IntervalSec == nil || *tuning.IntervalSec != 3600 {
t.Fatalf("没写盘也要回读真值:%+v", tuning.IntervalSec)
}
}
// TestPatchMemoryTidyTuning_RefusesUnreadableFile 盘上那份运行期读不走时拒绝覆盖:
// 返回 ErrInvalidMemoryTidy(调用方据此回 409),且**盘上一个字节都不动**。
func TestPatchMemoryTidyTuning_RefusesUnreadableFile(t *testing.T) {
t.Parallel()
for _, body := range []string{`{"intervalSec": "3600"}`, `not json at all`, `[1, 2]`} {
body := body
t.Run(body, func(t *testing.T) {
path := writeMemoryTidyFile(t, body)
_, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"intervalSec": 3600}), nil)
if !errors.Is(err, ErrInvalidMemoryTidy) {
t.Fatalf("err = %v, want ErrInvalidMemoryTidy", err)
}
if wrote {
t.Fatal("读不走的文件被写入了")
}
raw, _ := os.ReadFile(path)
if string(raw) != body {
t.Fatalf("坏文件被改动了:%s", raw)
}
})
}
}
// TestPatchMemoryTidyTuning_CreatesMissingFileAtomically 文件缺失 ⇒ 建目录并原子写出
// (temp + rename):成功之后不留 `.tmp`,权限 0600(Windows 不校验)。
func TestPatchMemoryTidyTuning_CreatesMissingFileAtomically(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "config", memoryTidyFileName)
tuning, wrote, err := PatchMemoryTidyTuning(path, patchTuning(t, map[string]any{"tempTtlSec": 604800}), nil)
if err != nil {
t.Fatalf("patch: %v", err)
}
if !wrote {
t.Fatal("缺失文件时应当写出")
}
if tuning.TempTtlSec == nil || *tuning.TempTtlSec != 604800 {
t.Fatalf("回读真值 = %+v", tuning.TempTtlSec)
}
if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("原子写留下了临时文件:err=%v", err)
}
if info, err := os.Stat(path); err != nil {
t.Fatalf("stat: %v", err)
} else if info.Mode().Perm() != memoryTidySaveFileMode && runtime.GOOS != "windows" {
t.Fatalf("文件权限 = %v, want %v", info.Mode().Perm(), memoryTidySaveFileMode)
}
}
+37 -2
View File
@@ -174,7 +174,20 @@ type PreInsertReviewer struct {
// 推进时钟(见 preinsert_t1_limit.go)。
t1Gate *t1RateGate
mu sync.Mutex
// mu 保护 running / cancel 两个生命周期字段,以及**运行期可变的那两个配置
// 字段**(`DisableIngestGate` / `TempTTL`,面板调优会改它们,见
// preinsert_tuning.go 的 ApplyPanelTuning)。
//
// 它用 RWMutex 而不是 Mutex,是因为后者在**写入路径**上被高频读:同步入库闸
// 每写一条记忆就要问一次 `gateEnabled()`,第五态 `defer` 还要问一次
// `deferDeadline()`;读锁之间不互斥,因此这两处不会因为面板保存而互相排队。
//
// 🔴 口径(切片 MEMPANEL / M4 立):**谁在运行期写 cfg,谁就必须让读它的地方
// 也持这把锁**。今天运行期只写上面那两个字段,因此只有读这两个字段的两处
// (`gateEnabled` / `deferDeadline`)走 `tuningSnapshot`;其余读点读的是构造期
// 冻结的字段,不与写方交手,故**没有**一并加锁(不给热路径加无谓的锁)。
// 将来面板再接管别的字段,那几处读点必须与它一起收。
mu sync.RWMutex
running bool
cancel context.CancelFunc
wg sync.WaitGroup
@@ -208,10 +221,32 @@ func NewPreInsertReviewer(cfg ReviewConfig, deps ReviewDeps) (*PreInsertReviewer
}, nil
}
// tuningSnapshot 在读锁下取一份配置快照。
//
// 为什么需要它(切片 MEMPANEL / M4):面板调优(preinsert_tuning.go 的
// `ApplyPanelTuning`)会在**运行期**改 `cfg` 的两个字段;在这条特性之前 `cfg` 是
// 构造期冻结的,读侧无锁本来安全。凡是读这两个字段的地方因此都必须与写方同步,
// 否则就是「写方持锁、读方不持锁」的数据竞争(`go test -race` 可复现)。
//
// 取**整份**快照而不是逐个字段读,是为了让一次判定看到的是**同一个版本**的配置
// (两个字段分别读可能落到两次不同的写入之间)。
//
// 它只做一次字段拷贝、**不调用任何别的函数**,因此不会引入新的锁序:写方
// (ApplyPanelTuning)持写锁时也只做字段赋值,不会再去等别的锁。
func (r *PreInsertReviewer) tuningSnapshot() ReviewConfig {
r.mu.RLock()
defer r.mu.RUnlock()
return r.cfg
}
// gateEnabled 报告**同步入库闸**是否生效(任务书规格 3 的开关)。
// 总开关关闭或闸被显式关掉时它都为 false,此时写入路径逐字回到「只插不审」。
func (r *PreInsertReviewer) gateEnabled() bool {
return r != nil && r.cfg.Enabled && !r.cfg.DisableIngestGate
if r == nil {
return false
}
cfg := r.tuningSnapshot()
return cfg.Enabled && !cfg.DisableIngestGate
}
// gateScanLimit 返回同步闸实际使用的候选上限:配置值与其默认值的较小者 ——
+1 -1
View File
@@ -237,7 +237,7 @@ func (r *PreInsertReviewer) planFromVerdict(item MemoryItem, v ingestVerdict) in
// 闸(同步)与异步审查层**共用这一个函数**:同一份配置、同一种时刻语义,因此
// 同一条记忆无论被哪一层判成 defer,拿到的有效期都来自同一个算式。
func (r *PreInsertReviewer) deferDeadline(now time.Time) time.Time {
return now.UTC().Add(r.cfg.TempTTL)
return now.UTC().Add(r.tuningSnapshot().TempTTL)
}
// failOpenPlan 组装 fail-open 计划:照常插入(active)+ 投既有队列 + 留痕。
@@ -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,39 @@ 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`(代码
// 默认)接住 —— 这两处仍是**唯一**实现点,本方法不另写一套回落规则。
//
// 🔴 并发口径(本方法引入的运行期写入面,已连同读侧一起收口):`cfg` 在这条特性
// 之前是**构造期冻结**的,读侧无锁本来安全;这里开了「运行期改两个字段」这条路,
// 于是读这两个字段的地方也必须同步。写侧持 `r.mu` 的**写锁**;读侧两处
// (`gateEnabled` / `deferDeadline`)走 `r.tuningSnapshot()` 取读锁快照。
// 其余读点读的是构造期冻结的字段(`Enabled` / `QueueSize` / `Timeout` / `Threshold` /
// T1 四件套…),与写方不交手,因此**刻意没有**一并加锁(不给入库热路径加无谓的锁);
// 将来面板再接管别的字段,那些读点必须与它一起收。
//
// 锁序:本方法持锁期间**只做字段赋值**,不去取任何别的锁(`normalizeReviewConfig` /
// `ApplyReviewTuning` 都在取锁**之前**跑完)⇒ 不引入新的锁序,也就不可能与既有路径
// 形成环。
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
}
+5 -1
View File
@@ -142,7 +142,11 @@ func (t *Tidier) logDryRun(out TidyDryRun, ledger *recallLedger) {
// 配置关闭(MEMORY_TIDY_ENABLED=false)时如实返回 ErrTidyDisabled:关闭是显式
// 选择,试算不绕过它,也不回一份空候选假装「没有建议」。
func (s *TidyService) DryRun(ctx context.Context) (TidyDryRun, error) {
if !s.cfg.Enabled {
// 走 `enabled()` 而不是裸读字段:这个判断在取 runMu **之前**,而面板调优能在运行期
// 覆写 `s.cfg`(见 tidy_service_tuning.go 的 ApplyPanelTuning)⇒ 该字段的每个读点
// 都得与写方同步,否则 `POST …/tidy/dry-run`(含 `?async=1` 那条后台版)与保存并发
// 时就是数据竞争(`-race` 可复现)。
if !s.enabled() {
return TidyDryRun{}, ErrTidyDisabled
}
if ctx == nil {
+86 -30
View File
@@ -63,8 +63,23 @@ type TidyService struct {
now func() time.Time
// wait 是心跳的等待原语:返回 false 表示 ctx 已取消。生产是 time.Timer;
// 测试注入假等待(推进假时钟后立刻返回),因此补跑判据可以确定性验证。
//
// 🔴 它的返回值语义**没有变**(`false` 仍表示「这一觉没睡成就结束了」):
// 面板调优的重排**不是**靠它自报,而是靠下面那个可被取消的子 ctx —— 于是注入的
// 假等待(不认识 wake 的那些)在测试里语义逐字不变(见 loop 的等待段)。
wait func(ctx context.Context, d time.Duration) bool
// wake 是「面板调优改了配置 ⇒ 把这一觉重排一次」的一次性标记:**有缓冲(cap 1)
// + 非阻塞发**,见 tidy_service_tuning.go 的 notifyReschedule。写端跑在面板的
// HTTP 保存路径上,因此**绝不允许阻塞**;满了丢弃是**正确**语义(通道里那一个
// 标记表达的就是「有一次待处理的重排」,重算是幂等的)。
wake chan struct{}
// wakeMu 保护 wakeCancel(= 取消「当前这一次等待」的手段;两次等待之间是 nil)。
// 它是 wake 的**中断**那一半:只有标记没有中断的话,新周期要等这一觉(可能是一天)
// 睡完才生效 —— 那正是主人把 24h 改成 3h 时看到的「改了跟没改一样」。
wakeMu sync.Mutex
wakeCancel context.CancelFunc
// runMu 保证同一时刻只有一轮整理(design/23 §9 单写者):心跳与手动触发
// 若撞在一起,后者排队而不是并发改写候选表。
runMu sync.Mutex
@@ -86,6 +101,7 @@ func NewTidyService(cfg TidyConfig, deps TidyDeps) (*TidyService, error) {
log: tidier.log,
now: tidier.now,
wait: waitTidyTimer,
wake: make(chan struct{}, 1),
runs: newTidyRunRegistry(defaultTidyRunTTL),
}, nil
}
@@ -106,7 +122,26 @@ func waitTidyTimer(ctx context.Context, d time.Duration) bool {
func (s *TidyService) SessionID() string { return TidySessionID }
// Enabled 报告整理是否被配置开启(design/23 §4 的 MEMORY_TIDY_ENABLED)。
func (s *TidyService) Enabled() bool { return s.cfg.Enabled }
//
// 它走 `enabled()` 而不是直接读字段:`Enabled` 是 `s.cfg` 里的一个字段,而面板调优
// 能在**运行期**整体覆写 `s.cfg`(见 tidy_service_tuning.go 的 ApplyPanelTuning),
// 因此该字段的**每一个**读点都必须与写方同步(`s.mu`)。
func (s *TidyService) Enabled() bool { return s.enabled() }
// enabled 在读锁下报告整理开关。它是 `s.cfg.Enabled` 的**唯一**安全读法
// (口径见 scheduleSnapshot 与上面 Enabled 的注释)。
func (s *TidyService) enabled() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.cfg.Enabled
}
// cfgSnapshot 在读锁下取一份配置快照:凡是不需要 lastRun 的读点用它。
func (s *TidyService) cfgSnapshot() TidyConfig {
s.mu.Lock()
defer s.mu.Unlock()
return s.cfg
}
// Start 启动心跳。它是幂等的:第二次调用是空操作。配置关闭时只记一条日志
// 并立即返回(关闭是显式选择,绝不静默)。ctx(可为 nil)是所有内部工作的
@@ -117,7 +152,7 @@ func (s *TidyService) Enabled() bool { return s.cfg.Enabled }
// 整理被配置关掉时清扫也不跑(与 `preinsert_gc.go` 的清扫随审查服务启停同口径),
// `Stop` 会一并取消并 join 它。
func (s *TidyService) Start(ctx context.Context) {
if !s.cfg.Enabled {
if !s.enabled() {
s.log.Info("memory tidy disabled by configuration", "env", "MEMORY_TIDY_ENABLED")
return
}
@@ -164,16 +199,18 @@ func (s *TidyService) Start(ctx context.Context) {
go s.loop(cctx)
go s.expirySweepLoop(cctx)
// 启动日志逐项取一次快照(面板调优能在运行期覆写 cfg,因此这些字段也不能裸读)。
cfg := s.cfgSnapshot()
s.log.Info("memory tidy started",
"interval", s.cfg.Interval.String(),
"quiet_interval", s.cfg.QuietInterval.String(),
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", s.cfg.QuietStartHour, s.cfg.QuietEndHour),
"recall_threshold", s.cfg.RecallThreshold,
"scan_limit", s.cfg.ScanLimit,
"compare_limit", s.cfg.CompareLimit,
"candidate_limit", s.cfg.CandidateLimit,
"auto_apply", s.cfg.autoApplyEnabled(),
"auto_apply_per_round", s.cfg.AutoApplyPerRound,
"interval", cfg.Interval.String(),
"quiet_interval", cfg.QuietInterval.String(),
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", cfg.QuietStartHour, cfg.QuietEndHour),
"recall_threshold", cfg.RecallThreshold,
"scan_limit", cfg.ScanLimit,
"compare_limit", cfg.CompareLimit,
"candidate_limit", cfg.CandidateLimit,
"auto_apply", cfg.autoApplyEnabled(),
"auto_apply_per_round", cfg.AutoApplyPerRound,
"rule_version", TidyRuleVersion,
"session_id", TidySessionID,
"last_run_at", s.lastRunLabel())
@@ -261,7 +298,7 @@ func (s *TidyService) RunOnce(ctx context.Context) (TidyStats, error) {
// 由 tidy_auto.go 直接落地,其余仍等人点(候选 apply / 按 run 批量 apply)。
// 关掉自动档只需 `$DATA_DIR/config/memory-tidy.json` 的 `{"autoApply": false}`。
func (s *TidyService) ManualRun(ctx context.Context) (TidyStats, error) {
if !s.cfg.Enabled {
if !s.enabled() {
return TidyStats{}, ErrTidyDisabled
}
return s.runRound(ctx, "manual")
@@ -280,11 +317,19 @@ func (s *TidyService) loop(ctx context.Context) {
defer s.wg.Done()
s.catchUp(ctx)
for {
if !s.wait(ctx, s.nextDelay()) {
return
}
if ctx.Err() != nil {
// 上一轮攒下的「调优唤醒」标记在这里清掉:它全部的含义就是「把下一拍重算
// 一遍」,因此必须在重算**之前**清空 —— 否则重算完发现标记还在,会自旋。
s.drainWake()
switch s.waitForTick(ctx) {
case tidyTickStopped:
// 父 ctx 被取消(Stop / 进程退出),或注入的假等待自报「停」:既有语义。
return
case tidyTickWokenByTuning:
// 被**面板调优**唤醒:这一觉是按**旧**周期算出来的,现在作废 ⇒ 只重算
// 下一拍,**绝不**因此跑一轮(唤醒不是「到点了」,判据仍只有 dueNow)。
s.log.Debug("memory tidy heartbeat rescheduled by panel tuning",
"now_hour", s.now().Hour())
continue
}
// 到点即跑:间隔用的是**此刻**的时段(nextDelay 里现算的),因此跨过
// 安静时段边界后,下一轮自动换档。判据再次复核是因为等待期间可能有别的
@@ -330,21 +375,19 @@ func (s *TidyService) catchUp(ctx context.Context) {
// nextDelay 用同一个函数,因此「下一拍的时间」与「到没到点」不可能各说各话。
func (s *TidyService) dueNow() (bool, string) {
now := s.now()
s.mu.Lock()
last := s.lastRun
s.mu.Unlock()
cfg, last := s.scheduleSnapshot()
if last.IsZero() {
if s.cfg.inQuietHours(now) {
if cfg.inQuietHours(now) {
return true, "no_history_in_quiet_window"
}
return false, ""
}
if now.Sub(last) >= s.cfg.intervalAt(now) {
if now.Sub(last) >= cfg.intervalAt(now) {
return true, "interval_elapsed"
}
if s.cfg.inQuietHours(now) {
if start := s.cfg.currentQuietStart(now); last.Before(start) {
if cfg.inQuietHours(now) {
if start := cfg.currentQuietStart(now); last.Before(start) {
return true, "quiet_window_not_run"
}
}
@@ -368,18 +411,18 @@ func (s *TidyService) dueNow() (bool, string) {
// 把它挪回启动时算一次 ⇒ 白天/晚上的切换不会生效(本切片有测试钉住这条)。
func (s *TidyService) nextDelay() time.Duration {
now := s.now()
s.mu.Lock()
last := s.lastRun
s.mu.Unlock()
cfg, last := s.scheduleSnapshot()
// lastRun 与 cfg 必须在**同一把锁**下取到同一版本(见 scheduleSnapshot),
// 否则一次面板保存可能让这里拿到「新周期 + 旧历史」的混合体。
var next time.Time
if last.IsZero() {
next = s.cfg.nextQuietStart(now)
if s.cfg.quietWindowDisabled() {
next = now.Add(s.cfg.intervalAt(now))
next = cfg.nextQuietStart(now)
if cfg.quietWindowDisabled() {
next = now.Add(cfg.intervalAt(now))
}
} else {
next = last.Add(s.cfg.intervalAt(now))
next = last.Add(cfg.intervalAt(now))
}
delay := next.Sub(now)
if delay < minTidyTickDelay {
@@ -388,6 +431,19 @@ func (s *TidyService) nextDelay() time.Duration {
return delay
}
// scheduleSnapshot 在锁下同时取「调度配置」与「上次运行时刻」。
//
// 🔴 为什么要有它(切片 MEMPANEL / M4 立):`cfg` 在这条特性之前是启动期冻结的,
// 因此调度读(`nextDelay` / `dueNow`)直接读字段本来安全;面板调优开了「运行期改
// cfg」这条路之后,调度读**必须**与写方(tidy_service_tuning.go 的 ApplyPanelTuning)
// 同步,否则就是数据竞争(`-race` 可复现)。两样东西一起取,是为了让一次判定看到
// 的是**同一个版本**(分开取可能落在两次不同的写入之间)。
func (s *TidyService) scheduleSnapshot() (TidyConfig, time.Time) {
s.mu.Lock()
defer s.mu.Unlock()
return s.cfg, s.lastRun
}
// runRound 跑一轮并把结果与审计留痕。单写者:runMu 串行化心跳与手动触发。
func (s *TidyService) runRound(ctx context.Context, trigger string) (TidyStats, error) {
s.runMu.Lock()
@@ -0,0 +1,211 @@
package memory
import (
"context"
"fmt"
"log/slog"
"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 小时时,新周期要等那一觉睡完才生效,等于「改了跟没改一样」。
// 重排的做法是**唤醒**(`notifyReschedule`:一次性标记 + 取消当前那次等待),
// **不是** `Stop` + `Start`。这条是本切片修正后的形态,理由见下面「为什么不是
// Stop/Start」。
// 3. **失败不吞值**:重排不成立时值**照样**落到运行期(下一次调度判定就用它),
// 调用方据实回报 —— 绝不假装热生效,也绝不让保存白写。
//
// 为什么不是 `Stop` + `Start`(早先那版就是它,被塔的 review 打回):`Stop` 会连同
// `Start` 里起的**另一只后台件**(到期清扫 `expirySweepLoop`)一起取消,而清扫是
// **启动即扫一轮**的形态 ⇒ 每次面板保存都会顺带触发一次真实删行 + 逐行审计,还会把
// 清扫的 ticker 重置(`design/23 §19.1` 的「到期后 ≤ 一个清扫周期」因此被推后)。
// 更要命的是「停止中重启」这件事本身:`Stop` 的超时分支里 `wg.Wait` 仍在飞,紧接着
// `Start` 的 `wg.Add` 会踩上 `tidy_service.go` 里 M3 明文写下的 WaitGroup 复用纪律
// (交叉调用 Start/Stop 在 `-race` 下 `panic: sync: WaitGroup is reused before previous
// Wait has returned`)。唤醒通道把这两件事一起消掉:**调优路径上一个 Start/Stop 都没有**。
//
// 并发口径:`s.cfg` 由 `s.mu` 互斥 —— 🔴 **按字段扫,不按文件扫**:这个字段既然会被
// 运行期整体覆写,它的**每一个**读点都必须与写方同步。做法是读写都在 `s.mu` 下:读点
// 收进三个访问器 `enabled()`(开关)/ `cfgSnapshot()`(整份)/ `scheduleSnapshot()`
// (整份 + lastRun),覆盖调度读 `nextDelay` / `dueNow`、两个活端点的开关判定
// `DryRun` / `ManualRun`、`Enabled()` 与 `Start` 的启动日志;此外 `ApplyPanelTuning`
// 里还有一处**同一把锁下**的相等比较(`s.cfg == next`),它本身不需要访问器。写 `tidier.cfg` 则与**所有轮次**由 `s.runMu` 互斥(心跳轮、手动轮、
// 试算都持它)。因此这里只剩下「持两把锁写 → 发一次唤醒」,不与任何既有锁序冲突。
// ApplyPanelTuning 把面板值当场叠到运行期(见文件头三条口径)。
//
// 返回 nil 表示**值已生效,且心跳已被告知重排**(下一拍按新周期算)。当前实现里
// 它**不会失败**(错误返回保留给将来的校验),因此网关侧的 `hot` 语义就是「值已叠进
// 运行期」。
func (s *TidyService) ApplyPanelTuning(base TidyConfig, panel runtimeconfig.MemoryTidyTuning) error {
next := tidyPanelTuning(base, panel, s.log)
s.mu.Lock()
unchanged := s.cfg == next
s.mu.Unlock()
if unchanged {
// 值与运行期**完全一致**(面板这次只改了别的键)⇒ 一个字段都不用写,也
// 不必唤醒心跳。刻意**不**走下面那条写路径:写一个「相同的值」也是写,
// 照样会与调度读构成竞争,而它一点收益都没有。
return nil
}
// 写运行期(两处持有者),再把「这一觉是按旧周期算的」这件事作废。
s.applyTidyConfig(next)
s.notifyReschedule()
s.log.Info("memory tidy panel tuning applied",
"interval_sec", next.IntervalSec, "quiet_interval_sec", next.QuietIntervalSec,
"quiet_hours", fmt.Sprintf("%02d:00-%02d:00", next.QuietStartHour, next.QuietEndHour),
"auto_apply", !next.DisableAutoApply, "source", "admin panel")
return nil
}
// notifyReschedule 通知心跳「周期变了 ⇒ 把这一觉重排一次」。
//
// 两件事,缺一不可:
// - **标记**(非阻塞发一个有缓冲的通道):保证这次通知**不会丢** —— 如果心跳此刻
// 不在等(正在跑一轮、或还没 Start),标记会留在缓冲里,等它下一次「等」之前被
// 清掉并重算。cap 1 + `default` 是刻意的:调用方在**面板保存的 HTTP 路径**上,
// 绝不能阻塞;而通道里那一个标记表达的就是「有一次待处理的重排」,重算是幂等的,
// 因此满了直接丢弃是**正确**语义(丢的只是重复通知,不是改动)。
// - **中断**(取消当前那次等待):保证这次通知**立刻**生效 —— 只有标记的话,新的
// 24h→3h 要等原来那一觉(可能是一天)睡完才落地,那正是「改了跟没改一样」。
//
// 两件事都不碰生命周期(不 Start / 不 Stop / 不动 wg 与 running),因此到期清扫不会
// 被顺带停掉或重启(见文件头「为什么不是 Stop + Start」)。
func (s *TidyService) notifyReschedule() {
select {
case s.wake <- struct{}{}:
default:
}
s.wakeMu.Lock()
cancel := s.wakeCancel
s.wakeMu.Unlock()
if cancel != nil {
cancel()
}
}
// armWake 登记「取消当前这一次等待」的手段;下一次调优保存靠它把这一觉当场作废。
func (s *TidyService) armWake(cancel context.CancelFunc) {
s.wakeMu.Lock()
s.wakeCancel = cancel
s.wakeMu.Unlock()
}
// disarmWake 撤掉登记(每次等待结束都要做,且必须在取消子 ctx **之前**)——
// 否则下一次等待会被上一次的取消误伤。
func (s *TidyService) disarmWake() {
s.wakeMu.Lock()
s.wakeCancel = nil
s.wakeMu.Unlock()
}
// drainWake 清掉待处理的唤醒标记(非阻塞)。心跳在**重算下一拍之前**调它,否则
// 标记会让「被唤醒 ⇒ 重算 ⇒ 又被唤醒」变成自旋。
func (s *TidyService) drainWake() {
select {
case <-s.wake:
default:
}
}
// tidyTickOutcome 是一次「等下一拍」的结局。
type tidyTickOutcome int
const (
// tidyTickWaited:睡满了(或注入的假等待自报可以继续)⇒ 照常做 dueNow 判定。
tidyTickWaited tidyTickOutcome = iota
// tidyTickWokenByTuning:被面板调优的重排打断 ⇒ 只重算下一拍,**不跑轮**。
tidyTickWokenByTuning
// tidyTickStopped:父 ctx 被取消,或注入的假等待自报「停」⇒ 退出循环。
tidyTickStopped
)
// waitForTick 睡到下一拍,返回这次等待的结局(见 tidyTickOutcome)。
//
// 三步的顺序不能变:
// 1. **先登记**取消手段(armWake)—— 否则调优那一下没法作废这一觉;
// 2. 调**注入的** `wait`(生产是 waitTidyTimer;测试可注入假等待),但给它的是一份
// **可取消的子 ctx** ⇒ 唤醒 = 取消它。`wait` 的返回语义因此**一个字都没改**:
// `false` 仍表示「没睡成就结束了」(生产实现里它只在 ctx 结束时返回 false)。
// 3. 无论结果如何都撤掉登记并回收子 ctx。
//
// 判定顺序是刻意的:**先看父 ctx**,再看「子 ctx 是不是被取消」(= 调优唤醒)。反过来
// 的话,Stop 的那一下会被读成「一次唤醒」,心跳就静默没了。
func (s *TidyService) waitForTick(ctx context.Context) tidyTickOutcome {
wctx, wake := context.WithCancel(ctx)
s.armWake(wake)
waited := s.wait(wctx, s.nextDelay())
s.disarmWake()
woken := wctx.Err() != nil
wake()
switch {
case ctx.Err() != nil:
return tidyTickStopped
case woken:
return tidyTickWokenByTuning
case !waited:
return tidyTickStopped
default:
return tidyTickWaited
}
}
// 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)
}
// applyTidyConfig 把新配置落到**两个**持有者上:
// - `s.cfg` 是调度判据(`intervalAt` / `inQuietHours`)的来源(`s.mu` 下写);
// - `s.tidier.cfg` 是每一轮实际用的阈值/上限/自动档开关的来源。
//
// 两处都要写:只写前者 ⇒ 轮次仍用旧阈值;只写后者 ⇒ 调度仍按旧周期。
//
// 它自己拿 `runMu`(排除并发的一轮:心跳轮与手动轮 / 试算都持它)—— 调优路径**不等
// 任何后台件停**:`s.cfg` 的读点全部持 `s.mu`(见文件头),`tidier.cfg` 的读点全部持
// `runMu`,因此这把锁足以让写入与每一轮的读不交手,不需要(也不再)停心跳。
func (s *TidyService) applyTidyConfig(cfg TidyConfig) {
s.runMu.Lock()
defer s.runMu.Unlock()
s.mu.Lock()
s.cfg = cfg
s.mu.Unlock()
s.tidier.cfg = cfg
}
@@ -0,0 +1,532 @@
package memory
import (
"context"
"errors"
"strings"
"sync"
"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)
}
}
// tuneWaitProbe 是一个「听得懂 ctx 取消」的假等待:记录每次被要求的时长,并在
// ctx 结束时立刻返回 false —— 与生产的 `waitTidyTimer` 同语义。因此它既能被
// **调优唤醒**打断(唤醒 = 取消这一次等待的子 ctx),也能被 Stop 停住。
type tuneWaitProbe struct {
mu sync.Mutex
delays []time.Duration
calls chan time.Duration
}
func newTuneWaitProbe() *tuneWaitProbe {
return &tuneWaitProbe{calls: make(chan time.Duration, 32)}
}
func (p *tuneWaitProbe) wait(ctx context.Context, d time.Duration) bool {
p.mu.Lock()
p.delays = append(p.delays, d)
p.mu.Unlock()
select {
case p.calls <- d:
default:
}
select {
case <-ctx.Done():
return false
case <-time.After(30 * time.Second):
// 兜底:用例失败时才可能走到这里(正常路径都由取消叫醒)。
return false
}
}
// awaitCall 等下一次「等待」被调用并返回它要求的时长(超时即 FAIL)。
func (p *tuneWaitProbe) awaitCall(t *testing.T, step string) time.Duration {
t.Helper()
select {
case d := <-p.calls:
return d
case <-time.After(5 * time.Second):
t.Fatalf("等心跳走到「%s」超时(循环没走到预期阶段)", step)
return 0
}
}
// TestTidyServiceApplyPanelTuning_WakesHeartbeatToNewCadence 钉住**热生效的新机制**
// (唤醒,而不是 Stop/Start):
//
// ① 保存前那一觉是按**旧**周期算的(15:00 起、上次 14:30、周期 24h ⇒ 等 23h30m);
// ② 保存**当场**把那一觉作废并按新周期重排(下一觉 = 30m),不必等旧周期睡完;
// ③ 唤醒**不跑轮**(`lastRun` 不前移 —— 到没到点仍只由 dueNow 判);
// ④ 心跳**没有**被停过:`running` 仍为 true,等待由**同一个**循环继续(不是重启)。
func TestTidyServiceApplyPanelTuning_WakesHeartbeatToNewCadence(t *testing.T) {
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)
// 未启动:保存只改配置,不碰心跳状态(也**不**该把它启动起来)。
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 时不该因为保存而启动心跳")
}
probe := newTuneWaitProbe()
svc.wait = probe.wait
svc.mu.Lock()
svc.lastRun = at.Add(-30 * time.Minute)
svc.mu.Unlock()
svc.Start(nil)
t.Cleanup(func() { _ = svc.Stop(nil) })
if got := probe.awaitCall(t, "第一觉"); got != 23*time.Hour+30*time.Minute {
t.Fatalf("第一觉 = %s, want 23h30m(按旧周期)", got)
}
// 保存:周期 24h → 1h。这一觉必须**当场**作废并重排到 30 分钟。
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil {
t.Fatalf("ApplyPanelTuning(running): %v", err)
}
if got := probe.awaitCall(t, "被唤醒后的第二觉"); got != 30*time.Minute {
t.Fatalf("第二觉 = %s, want 30m(仍按旧周期说明没被唤醒)", got)
}
svc.mu.Lock()
last, runningAfter := svc.lastRun, svc.running
svc.mu.Unlock()
if !runningAfter {
t.Fatal("唤醒后心跳必须仍在跑(唤醒不是停表)")
}
if !last.Equal(at.Add(-30 * time.Minute)) {
t.Fatalf("唤醒跑了轮:lastRun = %v, want 14:30(唤醒只重排,不跑轮)", last)
}
}
// TestTidyServiceApplyPanelTuning_DoesNotSweepExpiredRows 钉住「重排不碰生命周期」:
// 面板保存**不再**顺带触发一次到期清扫,也**不再**重启清扫的 ticker。
//
// 判据用行为而不是实现:保存前后各造一行已到期的记忆,保存之后它必须**还在**。
// 若保存走了 Stop/Start(早先那版就是),`expirySweepLoop` 的「启动即扫一轮」会
// 立刻把它删掉 —— 这正是塔要证伪的那条副作用。
func TestTidyServiceApplyPanelTuning_DoesNotSweepExpiredRows(t *testing.T) {
ctx := context.Background()
now := tidyTestTime(t)
store := newTestStore(t)
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
var (
mu sync.Mutex
audits []string
)
svc := newTidyServiceForTest(t, store, base, nil, fixedTidyClock(now),
func(_, what, result string) {
mu.Lock()
defer mu.Unlock()
audits = append(audits, what+" "+result)
})
// 假等待:把心跳停在 ctx 上(本轮只关心清扫那一路,心跳不搅局)。
svc.wait = func(ctx context.Context, _ time.Duration) bool {
<-ctx.Done()
return false
}
expireAudits := func() int {
mu.Lock()
defer mu.Unlock()
n := 0
for _, a := range audits {
if strings.HasPrefix(a, expireAuditVerb+" ") {
n++
}
}
return n
}
// ① 先证明清扫确实在跑:启动清扫会把这一行删掉。
first := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "启动清扫应当删掉它", ExpiresAt: now.Add(-time.Minute),
})
svc.Start(ctx)
t.Cleanup(func() {
stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = svc.Stop(stopCtx)
})
waitUntilRowGone(t, store, first.ID)
afterStart := expireAudits()
// ② 再造一行已到期的,这次没有任何「启动清扫」来删它。
pending := addItem(t, store, MemoryItem{
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindRelationship,
Content: "面板保存不该把它删掉", ExpiresAt: now.Add(-time.Minute),
})
// ③ 面板保存(值真的变了,因此一定会走重排)。
if err := svc.ApplyPanelTuning(base, runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600)}); err != nil {
t.Fatalf("ApplyPanelTuning: %v", err)
}
// 给「万一真被触发了」留出显形窗口:启动清扫只需一次 SQLite 往返(微秒级)。
time.Sleep(200 * time.Millisecond)
if !memoryRowExists(t, store, pending.ID) {
t.Fatal("面板保存触发了到期清扫(那行被删了)—— 重排不该碰生命周期")
}
if got := expireAudits(); got != afterStart {
t.Fatalf("面板保存后多出了清扫审计:%d ⇒ %d", afterStart, got)
}
svc.mu.Lock()
running := svc.running
svc.mu.Unlock()
if !running {
t.Fatal("面板保存不该把心跳停掉")
}
}
// 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 的转调没有落到审查服务上")
}
}
// TestPreInsertReviewer_PanelTuningRaceWithGateReads 是**并发回归哨兵**(切片
// MEMPANEL / M4 的快修)。
//
// 背景:面板调优(`ApplyPanelTuning`)会在**运行期**改 `cfg` 的两个字段
// (`DisableIngestGate` / `TempTTL`)。在这条特性之前 `cfg` 是构造期冻结的,读侧
// 无锁本来安全;有了运行期写入之后,凡是读这两个字段的地方都必须与写方同步 ——
// 否则就是「写方持锁、读方不持锁」的数据竞争。读侧两处分别是:
// - `gateEnabled()`(同步入库闸,记忆**写入路径**上调用);
// - `deferDeadline()`(第五态 `defer` 落 `expires_at`,写入路径与异步审查都会调)。
//
// 它**只在 `-race` 下有意义**:不带 `-race` 时它只是一次并发冒烟(两边都跑完即通过),
// 有没有竞争交给 race detector 判。跑法:
//
// go test -race -count=1 -run TestPreInsertReviewer_PanelTuningRaceWithGateReads ./internal/runtime/memory/
func TestPreInsertReviewer_PanelTuningRaceWithGateReads(t *testing.T) {
store := newTestStore(t)
reviewer, err := NewPreInsertReviewer(ReviewConfig{Enabled: true}, ReviewDeps{Store: store, Log: testLogger()})
if err != nil {
t.Fatalf("NewPreInsertReviewer: %v", err)
}
const rounds = 200
var wg sync.WaitGroup
wg.Add(3)
// 写方:模拟面板保存(每次改两个面板键)。
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
panel := runtimeconfig.MemoryTidyTuning{
IngestGate: tuningBool(i%2 == 0),
TempTtlSec: tuningInt(3600 + i),
}
if err := reviewer.ApplyPanelTuning(panel); err != nil {
t.Errorf("ApplyPanelTuning: %v", err)
return
}
}
}()
// 读方一:同步入库闸的开关判定(写入路径)。
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
_ = reviewer.gateEnabled()
}
}()
// 读方二:第五态的到期时刻。
go func() {
defer wg.Done()
for i := 0; i < rounds; i++ {
_ = reviewer.deferDeadline(time.Now())
}
}()
wg.Wait()
}
// TestTidyService_PanelTuningRaceWithLiveHeartbeat 是整理档的**并发回归哨兵**。
//
// 背景(实测踩出来的,不是推演):面板调优会在运行期**整体覆写** `s.cfg`,因此该字段
// 的**每一个**读点都必须与写方同步。心跳那一路的读点(`nextDelay` / `dueNow`)现在走
// `scheduleSnapshot()`(同一把锁下同取「cfg + lastRun」)—— 这条哨兵钉的就是它:早先
// 那版把写入放在 `Start` **之后**,新起的循环立刻读 `s.cfg` 重算下一拍,与写入并排
// (而且那一拍还可能按**旧**配置算出来),`-race` 会当场报出来。
//
// 读者刻意**不**另起 goroutine 调 `nextDelay()`:那个入口在包里只有心跳 goroutine
// 会调(生产代码不在别处调它),另起一个就是伪造一个生产不存在的调用方。这里直接让
// **真心跳**当读者(连跑多轮,每轮都换一次周期),与生产形态一致。
//
// 与它同族的另两条哨兵:审查档(`PreInsertReviewer` 的并发读写)与试算/手动跑那两个
// **活端点**(`TestTidyService_PanelTuningRaceWithDryRunGuard`)。
//
// 同样**只在 `-race` 下有意义**(不带 -race 时它是一次并发冒烟 + 值生效断言)。跑法:
//
// go test -race -count=1 -run TestTidyService_PanelTuningRaceWithLiveHeartbeat ./internal/runtime/memory/
func TestTidyService_PanelTuningRaceWithLiveHeartbeat(t *testing.T) {
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) })
const rounds = 25
for i := 0; i < rounds; i++ {
panel := runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600 + i)}
if err := svc.ApplyPanelTuning(base, panel); err != nil {
t.Fatalf("ApplyPanelTuning(%d): %v", i, err)
}
}
// 每轮重排之后心跳都必须还活着(唤醒**不是**停表:走 Stop/Start 那版才会把
// running 置为 false,忘了重启就静默停摆 —— 现在这条路径上根本没有 Start/Stop),
// 且最后一轮的周期真的生效在运行期。
svc.mu.Lock()
running := svc.running
svc.mu.Unlock()
if !running {
t.Fatal("连着保存多次之后心跳必须仍在跑")
}
if want := 3600 + rounds - 1; svc.cfg.IntervalSec != want {
t.Fatalf("最后一轮的 intervalSec = %d, want %d", svc.cfg.IntervalSec, want)
}
}
// TestTidyService_PanelTuningRaceWithDryRunGuard 是**第三条**并发回归哨兵
// (前两条:审查档的真并发读写、整理档的真心跳):面板调优覆写 `s.cfg` 与
// **试算 / 手动跑两个活端点的开关判定**并发。
//
// 为什么单独要这一条:`TidyService.DryRun` 与 `TidyService.ManualRun` 的
// `if !s.enabled()` 都发生在**取 runMu 之前**,不受 runMu 保护 —— 它们正是
// `POST /admin/memories/tidy/dry-run`(含 `?async=1` 那条会起后台 goroutine、真机上
// 跑 90s 级的版本)与 `POST /admin/memories/tidy/run` 这条读点。只把「调度读」收进
// 持锁访问器是不够的:**同一个字段的每一个读点**都要一起收。
//
// 形态是**两个墙钟窗口完全重叠**的连续调用(不是固定轮数)—— 这一点是被实测逼出来的:
// 早先用「写方 40 轮 / 读方 40 轮」时,写方那 40 次覆写在微秒级就跑完了,读方还在慢
// 端点里打转,于是「撤回 DryRun 同步」这个变异只红 4/8(`-count=1` 会漏掉约一半的
// 回归)。改成两侧各自跑满同一个 300ms 窗口后,变异 8/8 必红(实测数字见回报)。
//
// 写方每轮都换一个值:`ApplyPanelTuning` 对「配置没变」会早退(那是它的省事路径),
// 一直写同一个值就根本不会触发 `s.cfg` 的覆写,这条哨兵也就测不到东西。
//
// 它**只在 `-race` 下有意义**(不带 -race 时是一次并发冒烟 + 结果断言)。跑法:
//
// go test -race -count=1 -run TestTidyService_PanelTuningRaceWithDryRunGuard ./internal/runtime/memory/
func TestTidyService_PanelTuningRaceWithDryRunGuard(t *testing.T) {
base := TidyConfig{Enabled: true, IntervalSec: 86400, QuietStartHour: 23, QuietEndHour: 7}
svc := newTidyServiceForTest(t, newTestStore(t), base, nil, time.Now, nil)
const raceWindow = 300 * time.Millisecond
stop := make(chan struct{})
var wg sync.WaitGroup
// 写方:连续覆写 s.cfg(每次换一个周期)。
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; ; i++ {
select {
case <-stop:
return
default:
}
panel := runtimeconfig.MemoryTidyTuning{IntervalSec: tuningInt(3600 + i)}
if err := svc.ApplyPanelTuning(base, panel); err != nil {
t.Errorf("ApplyPanelTuning: %v", err)
return
}
}
}()
// 读方:两个活端点各起两路(撞上写窗口的机会更多;两路都不改语义)。
reader := func(name string, call func() error) {
defer wg.Done()
for {
select {
case <-stop:
return
default:
}
if err := call(); err != nil {
t.Errorf("%s: %v", name, err)
return
}
}
}
dryRun := func() error {
if _, err := svc.DryRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) {
return err
}
return nil
}
manualRun := func() error {
if _, err := svc.ManualRun(context.Background()); err != nil && !errors.Is(err, ErrTidyDisabled) {
return err
}
return nil
}
wg.Add(4)
go reader("DryRun", dryRun)
go reader("DryRun", dryRun)
go reader("ManualRun", manualRun)
go reader("ManualRun", manualRun)
time.Sleep(raceWindow)
close(stop)
wg.Wait()
}
@@ -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,278 @@
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 "整数"
}
}
// tuningValueClipRunes 是错误文案里回显「收到的值」的 rune 上限。
const tuningValueClipRunes = 40
// clipJSON 把收到的原始 JSON 片段截断成可安全回显的短串(错值可能很长)。
//
// 🔴 按 **rune** 截断,**不按字节**:值里完全可能是中文(主人就爱填中文),按字节切
// 会把一个多字节字符切成两半 —— 回显里因此出现半截 UTF-8(JSON 编码后变成 U+FFFD
// 替换符),而这条文案的用途正是让人**看清自己填错了什么**。手法与同包的
// `clipRunes`(notifications.go)同源,直接复用而不是再写一份。
func clipJSON(raw json.RawMessage) string {
return clipRunes(strings.TrimSpace(string(raw)), tuningValueClipRunes)
}
// 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,316 @@
package gateway
import (
"context"
"encoding/json"
"errors"
"net/http"
"os"
"strings"
"testing"
"time"
"unicode/utf8"
"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"
)
// 本文件是记忆整理「面板调优」写端点的**守卫用例**(helper 与读侧用例见
// admin_memory_tuning_test.go):三态里最要紧的那两条(非法值绝不落盘、坏文件绝不
// 覆盖)与热生效的两种结果(成功 / 如实报失败)都在这里逐条钉死。
//
// 拆成两个文件只为守 AGENTS §10 的文件长度上限,口径与另一半完全同源。
// 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("没有数据目录时不该解析出落点")
}
}
// TestAdminMemoryTidyTuningWrite_LongValueEchoIsRuneSafe 「非法值」的文案会把**收到的
// 值**回显出来(这是它的用途:让人看清自己填错了什么),而主人完全可能填中文 ——
// 回显必须按 rune 截断:按**字节**切会把一个汉字切成半截(非法 UTF-8,经 JSON 编码后
// 变成 U+FFFD 替换符),等于把「你填了什么」显示成乱码。
//
// 取值刻意让截断点**落在多字节字符中间**:JSON 值自带引号(1 字节),再让首个字符是
// 1 字节 ASCII、其余是 3 字节汉字 ⇒ 「引号 + a」占 2 字节,于是 s[:40] 覆盖「引号 + a +
// 12 个完整汉字」(2 + 36 = 38 字节)再切掉第 13 个汉字的**第三个字节**(它的前两个
// 字节被留下)⇒ 那半个汉字成了非法 UTF-8。若只堆汉字(引号 1 + 3n 字节),40 会恰好
// 落在 rune 边界上,那样这条用例会**假绿**(实测过:按字节截断也照样通过)。
func TestAdminMemoryTidyTuningWrite_LongValueEchoIsRuneSafe(t *testing.T) {
t.Parallel()
// ① 纯函数层:截断结果必须是合法 UTF-8(这一条与 JSON 层无关,最直接)。
long := json.RawMessage(`"a` + strings.Repeat("三", 60) + `"`)
clipped := clipJSON(long)
if !utf8.ValidString(clipped) {
t.Fatalf("clipJSON 切出了非法 UTF-8:%q", clipped)
}
if strings.ContainsRune(clipped, utf8.RuneError) {
t.Fatalf("clipJSON 结果里有替换符:%q", clipped)
}
// ② 端点层:400 文案里不许出现替换符,且开头几个字仍要如实回显。
env := newTuneEnv(t, Deps{})
before := env.seed(t, `{"intervalSec": 86400}`)
payload := `a` + strings.Repeat("三", 60)
resp, body := env.put(t, env.token(t, auth.RoleOwner), `{"quietStartHour": "`+payload+`"}`)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, body)
}
assertErrorCode(t, body, "invalid_payload")
var eobj struct {
Code string `json:"code"`
Message string `json:"message"`
}
if err := json.Unmarshal(decodeBody(t, body)["error"], &eobj); err != nil {
t.Fatalf("decode error: %v (body=%s)", err, body)
}
if !utf8.ValidString(eobj.Message) || strings.ContainsRune(eobj.Message, utf8.RuneError) {
t.Fatalf("回显里出现了替换符/非法 UTF-8(半个汉字被切了):%q", eobj.Message)
}
if !strings.Contains(eobj.Message, "a三三三") {
t.Fatalf("文案应当仍回显开头几个字:%q", eobj.Message)
}
if after := env.bytes(t); string(after) != string(before) {
t.Fatalf("非法输入改动了盘上内容:\nbefore=%s\nafter=%s", before, after)
}
}
@@ -0,0 +1,279 @@
package gateway
import (
"encoding/json"
"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"
)
// 本文件锁定记忆整理「面板调优」读写端点(`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)))
}
}
+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)))