feat(memory): 记忆整理**应用路径 + 调度修复 + 手动真跑**(TIDY-1,主人投诉的根因)
主人实测:memory_items 1191 行、候选 77 条全 pending、memory_tidy_runs 仅 1 行、两晚定时没跑。 根因两条(都在这片里修掉): ① **以前根本没有执行面** —— 只有只读试算,候选永远 pending ② **调度被重启饿死** —— interval=24h 的 ticker 从进程启动算起,两天重启十几次 ⇒ 24h 永远走不完 - 应用路径:逐条 / 按 run 批量(默认 100、硬顶 500、超限 truncated)/ revert;**软合并** (status + merged_into/merged_from/merge_count,**绝不改 content、行不删**)+ 审计 (memory.tidy.apply / memory.tidy.revert)+ **幂等**(候选状态条件更新 + 行 status 闸两道) - 调度:改成**到点即跑**(catchUp:无历史+窗口内 / 距上次 run ≥ interval / 窗口起点晚于上次 run), 「上次 run」以 memory_tidy_runs 为**持久事实**;注入时钟用例证明**重启不饿死** - 手动真跑端点 POST /admin/memories/tidy/run(同档鉴权 + 审计 + 回执:新增/合并/软删/耗时) - 候选表加 run_id 列 + 索引(前滚;顺序=建表→补列→建索引,否则既有库会 no such column) - 3 个变异自检(删 catchUp / 去掉两道 status 闸 / truncated 恒 false) ⚠️ 生产库零动作:1191 行真跑与 77 条候选应用**未执行**(按红线由主人确认后主会话执行) ⚠️ 契约段在报告里(candidates 列表/apply/revert/tidy.run),Docs/ 待登记
This commit is contained in:
1 parent
57d0e8c543
commit
454ddb0b70
18 files changed
+3181
-71
No files matched your search
@@ -97,6 +97,10 @@ type TidyRun struct {
|
||||
// TidyStats 是一轮整理的观测口径:**扫描 N / 候选 M / 建议 K / 否决原因分布**
|
||||
// (design/23 §9)。它同时写进日志与运行表,绝不静默。
|
||||
type TidyStats struct {
|
||||
// RunID 是本轮在 memory_tidy_runs 里的留痕 id(切片 TIDY-1):候选按它归属,
|
||||
// 「按 run 批量应用」因此有确定的抓手。留痕写失败时它仍是本轮已用的 id,
|
||||
// 调用方不该据此假装留痕存在。
|
||||
RunID string
|
||||
Scanned int
|
||||
Partitions int
|
||||
Compared int
|
||||
@@ -106,10 +110,15 @@ type TidyStats struct {
|
||||
Skipped int
|
||||
Vetoed int
|
||||
VetoReasons map[string]int
|
||||
// ByAction 是本轮**新建**候选按动作的分布(幂等跳过的 duplicates 不计,
|
||||
// 它们不是这一轮的产出)。它是「新增/合并/软删各多少」这条回执的领域来源。
|
||||
ByAction map[string]int
|
||||
// Errors 是本轮的失败计数(分区失败 + 候选写入失败),进运行表。
|
||||
Errors int
|
||||
// Truncated 是被上限截断的分区标签(扫描或比对上限),供运维放大配额。
|
||||
Truncated []string
|
||||
// DurationMS 是本轮的墙钟耗时(毫秒),与日志里的 duration_ms 同一算法。
|
||||
DurationMS int64
|
||||
}
|
||||
|
||||
// TidyDeps 是注入给整理器的依赖。Store 必需;其余可空并优雅降级。
|
||||
@@ -183,19 +192,24 @@ type tidyRoundPolicy struct {
|
||||
// 日志;只有「所有分区都失败」或运行留痕本身写不进去时才回错误。
|
||||
func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
started := t.now().UTC()
|
||||
// 运行 id 与轮次开始**同时**定下来:候选要按它归属(切片 TIDY-1 的按轮
|
||||
// 批量应用),运行留痕也要用它,因此不能等到扫完再生成。
|
||||
runID := mustNewTidyID()
|
||||
stats, partitions, err := t.beginRound(ctx)
|
||||
if err != nil {
|
||||
return stats, err
|
||||
}
|
||||
stats.RunID = runID
|
||||
|
||||
failures := t.scanPartitions(ctx, partitions, &stats, tidyRoundPolicy{
|
||||
sink: t.persistCandidates,
|
||||
sink: func(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
|
||||
t.persistCandidates(ctx, runID, cands, stats)
|
||||
},
|
||||
recall: t.fallbackRecall,
|
||||
})
|
||||
|
||||
run := TidyRun{
|
||||
// 运行 id 在这里就定下来:留痕与日志必须指向同一条运行记录。
|
||||
ID: mustNewTidyID(),
|
||||
ID: runID,
|
||||
StartedAt: started,
|
||||
FinishedAt: t.now().UTC(),
|
||||
Scanned: stats.Scanned,
|
||||
@@ -207,6 +221,7 @@ func (t *Tidier) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
VetoReasons: stats.VetoReasons,
|
||||
RuleVersion: TidyRuleVersion,
|
||||
}
|
||||
stats.DurationMS = run.FinishedAt.Sub(run.StartedAt).Milliseconds()
|
||||
if err := t.deps.Store.persistTidyRun(ctx, run); err != nil {
|
||||
failures = append(failures, err)
|
||||
}
|
||||
@@ -251,7 +266,10 @@ func (t *Tidier) logRun(stats TidyStats, run TidyRun) {
|
||||
// beginRound 开一轮:准备口径容器并列出待整理分区。分区列表拿不到时整轮失败,
|
||||
// 且**不写运行留痕** —— 那一轮根本没开始(口径与 design §9 一致)。
|
||||
func (t *Tidier) beginRound(ctx context.Context) (TidyStats, []tidyPartition, error) {
|
||||
stats := TidyStats{VetoReasons: make(map[string]int)}
|
||||
stats := TidyStats{
|
||||
VetoReasons: make(map[string]int),
|
||||
ByAction: make(map[string]int),
|
||||
}
|
||||
partitions, err := t.deps.Store.listTidyPartitions(ctx)
|
||||
if err != nil {
|
||||
return stats, nil, fmt.Errorf("memory/tidy: list partitions: %w", err)
|
||||
@@ -412,9 +430,12 @@ func (t *Tidier) classifyForTidy(it MemoryItem, stats *TidyStats) tidyDispositio
|
||||
}
|
||||
|
||||
// persistCandidates 落库一批候选:幂等键已存在时只计入 duplicates
|
||||
// (design/23 §9「重复跑不重复产候选」)。
|
||||
func (t *Tidier) persistCandidates(ctx context.Context, cands []TidyCandidate, stats *TidyStats) {
|
||||
// (design/23 §9「重复跑不重复产候选」),新建的计入 Candidates 并按动作进
|
||||
// ByAction(回执的领域来源)。runID 是本轮的留痕 id:「按 run 批量应用」据此
|
||||
// 找到这一轮产出的候选;幂等跳过的行保留它**首次**被产出的轮次,不被改写。
|
||||
func (t *Tidier) persistCandidates(ctx context.Context, runID string, cands []TidyCandidate, stats *TidyStats) {
|
||||
for _, c := range cands {
|
||||
c.RunID = runID
|
||||
created, err := t.deps.Store.insertTidyCandidate(ctx, c)
|
||||
if err != nil {
|
||||
stats.Errors++
|
||||
@@ -426,6 +447,9 @@ func (t *Tidier) persistCandidates(ctx context.Context, cands []TidyCandidate, s
|
||||
continue
|
||||
}
|
||||
stats.Candidates++
|
||||
if stats.ByAction != nil {
|
||||
stats.ByAction[string(c.Action)]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是记忆整理的**应用路径**(切片 TIDY-1;design/23 §7/§8 的执行面)。
|
||||
//
|
||||
// 动因是一条实测口径:整理只产候选、没有任何执行面,于是 77 条候选全 pending、
|
||||
// 两天一条没应用。本文件把候选真正落地,五条不可走样的口径:
|
||||
//
|
||||
// 1. **只走软动作**:merge = 软合并(被吸收行 status='merged' + merged_into=存活
|
||||
// id;存活行 merged_from/merge_count/updated_at 更新),archive = 软删
|
||||
// (status='archived'),reclassify = 改 kind。**绝不改任何一行的 content**、
|
||||
// **绝不物理删除**(design/23 §8:行保留 ⇒ 可 revert);
|
||||
// 2. **幂等**:候选状态的条件更新(`… WHERE id=? AND status='pending'` +
|
||||
// RowsAffected)是执行闸,记忆行的 `AND status='active'` 条件更新是第二道闸
|
||||
// —— 两道都在同一事务里,重复应用同一条候选不产生第二次副作用;
|
||||
// 3. **可 revert**:RevertTidyCandidate 把 merge 的被吸收行复原为 active、把
|
||||
// archive 的归档行复原、把 reclassify 的 kind 还原,并把候选退回 pending;
|
||||
// 4. **失败隔离**:单条候选失败只进 results(failed),批量里的其余条照跑
|
||||
// (design/23 §9 的失败隔离同口径);
|
||||
// 5. **动作闭集**:link / keep 是**只读建议**(design/23 §14:link 的存储形态未定),
|
||||
// 因此不可应用 —— 应用它只会得到 skipped/action_not_applyable,绝不假装做过。
|
||||
//
|
||||
// 作用域:应用只在候选自带的分区(persona_id + scope + project_id)内改行;
|
||||
// 候选与目标行的分区不一致时拒做(纵深防御 design/23 §5.2-7/8)。
|
||||
|
||||
// TidyApplyStatus 是一条候选应用结果的状态类别。
|
||||
type TidyApplyStatus string
|
||||
|
||||
const (
|
||||
// TidyApplyApplied 表示这次调用真的改了记忆(或真的撤销了改动)。
|
||||
TidyApplyApplied TidyApplyStatus = "applied"
|
||||
// TidyApplySkipped 表示没有改动,且**不是错误**:幂等命中(已决定)、
|
||||
// 动作不可应用、候选形状不完整。
|
||||
TidyApplySkipped TidyApplyStatus = "skipped"
|
||||
// TidyApplyFailed 表示尝试过并失败。它不影响批量里的其它候选。
|
||||
TidyApplyFailed TidyApplyStatus = "failed"
|
||||
)
|
||||
|
||||
// 跳过的**机器可读**原因(面板按它聚合,不必解析中文文案)。
|
||||
const (
|
||||
// tidySkipAlreadyDecided 表示这条候选已被决定过 —— 重复应用/重复回滚的幂等口径。
|
||||
tidySkipAlreadyDecided = "already_decided"
|
||||
// tidySkipNotApplied 表示回滚时这条候选并不在已应用状态。
|
||||
tidySkipNotApplied = "not_applied"
|
||||
// tidySkipActionNotApplyable 表示该动作没有执行语义(link/keep)。
|
||||
tidySkipActionNotApplyable = "action_not_applyable"
|
||||
// tidySkipSourceNotActive 表示候选指向的记忆行已不在册(已被别的动作改过)。
|
||||
tidySkipSourceNotActive = "source_not_active"
|
||||
// tidySkipSourceMissing 表示候选指向的记忆行不存在。
|
||||
tidySkipSourceMissing = "source_missing"
|
||||
// tidySkipPartitionMismatch 表示候选的分区与它指向的行不一致。
|
||||
tidySkipPartitionMismatch = "partition_mismatch"
|
||||
// tidySkipInvalidCandidate 表示候选形状不完整(缺 target / 缺 source / kind 非法)。
|
||||
tidySkipInvalidCandidate = "invalid_candidate"
|
||||
)
|
||||
|
||||
// 整理应用侧的哨兵错误。与 memory 包其余错误同一口径:调用点一律 %w 包裹,
|
||||
// 调用方用 errors.Is 判断。
|
||||
var (
|
||||
// ErrTidyCandidateNotFound 在按 id 操作一条不存在的候选时返回。
|
||||
ErrTidyCandidateNotFound = errors.New("memory: tidy candidate not found")
|
||||
// ErrTidyRunRecordNotFound 在按 run 批量应用而该轮**留痕行**不存在时返回 ——
|
||||
// 拿一个拼错的 runId 来时,必须是「查不到这一轮」,不是「这一轮没有待定候选」。
|
||||
// (与 ErrTidyRunNotFound 区分:那是异步试算的**进程内存**句柄,两者不是一回事。)
|
||||
ErrTidyRunRecordNotFound = errors.New("memory: tidy run record not found")
|
||||
// errTidyCandidateDecided 是事务内的幂等哨兵:候选状态的条件更新没命中,
|
||||
// 说明它已被决定过。它不越过本包(调用方看到的是 skipped/already_decided)。
|
||||
errTidyCandidateDecided = errors.New("memory/tidy: candidate already decided")
|
||||
// errTidyItemNotActive 是事务内的第二道闸哨兵:目标记忆行已不在册
|
||||
// (已被别的动作改过)。同样不越过本包。
|
||||
errTidyItemNotActive = errors.New("memory/tidy: item is not active")
|
||||
)
|
||||
|
||||
// TidyApplyOptions 是一次应用/回滚的请求口径。
|
||||
type TidyApplyOptions struct {
|
||||
// Actor 是决定者标签,落 memory_tidy_candidates.decided_by。
|
||||
Actor string
|
||||
// Limit 是**批量应用**的条数上限(非正取 defaultTidyApplyBatchLimit,
|
||||
// 超过 maxTidyApplyBatchLimit 时钳到它)。单条应用忽略它。
|
||||
Limit int
|
||||
}
|
||||
|
||||
// normalizedLimit 归一批量上限:非正取默认值、超上限钳位(与既有查询面的
|
||||
// limit 约定一致:钳位而不是报错)。
|
||||
func (o TidyApplyOptions) normalizedLimit() int {
|
||||
if o.Limit <= 0 {
|
||||
return defaultTidyApplyBatchLimit
|
||||
}
|
||||
if o.Limit > maxTidyApplyBatchLimit {
|
||||
return maxTidyApplyBatchLimit
|
||||
}
|
||||
return o.Limit
|
||||
}
|
||||
|
||||
// TidyApplyResult 是一条候选的应用/回滚结果。
|
||||
type TidyApplyResult struct {
|
||||
CandidateID string `json:"candidateId"`
|
||||
Action TidyAction `json:"action"`
|
||||
Status TidyApplyStatus `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
// AffectedIDs 是这次真的被改动的 memory_items id(merge=被吸收行;
|
||||
// archive=被归档行;reclassify=被改 kind 的行)。它既是回滚的抓手,
|
||||
// 也是「软删除后总行数不变」这条断言的证据面。
|
||||
AffectedIDs []string `json:"affectedIds"`
|
||||
// Error 是 failed 时的原因(面向运维;网关侧只记日志,不把原始正文上线)。
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// TidyApplyBatch 是一次批量应用的回执。Total 是命中筛选的**真总数**(不是
|
||||
// len(Results)),Truncated 报告「待定候选比上限多」—— 与扫描/候选上限的
|
||||
// 诚实口径一致(超限绝不静默)。
|
||||
type TidyApplyBatch struct {
|
||||
RunID string `json:"runId"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Results []TidyApplyResult `json:"results"`
|
||||
Applied int `json:"applied"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
// Merged / Archived / Reclassified 是**成功应用**的候选按动作分解
|
||||
// (skipped 的不计)—— 「新增/合并/软删各多少」这条回执的领域来源。
|
||||
Merged int `json:"merged"`
|
||||
Archived int `json:"archived"`
|
||||
Reclassified int `json:"reclassified"`
|
||||
}
|
||||
|
||||
// ApplyTidyCandidate 应用**一条**候选(design/23 §11 的 apply 粒度之一)。
|
||||
// 找不到候选返回 ErrTidyCandidateNotFound;已决定/不可应用/形状不完整都以
|
||||
// TidyApplySkipped 结果如实作答(不是错误 —— 幂等命中不该是错误)。
|
||||
func (s *Store) ApplyTidyCandidate(ctx context.Context, id string, opts TidyApplyOptions) (TidyApplyResult, error) {
|
||||
cand, err := s.GetTidyCandidate(ctx, id)
|
||||
if err != nil {
|
||||
return TidyApplyResult{}, err
|
||||
}
|
||||
return s.applyCandidate(ctx, cand, opts)
|
||||
}
|
||||
|
||||
// applyCandidate 是单条应用的真实实现(批量路径也走它,保证两种粒度同一套语义)。
|
||||
func (s *Store) applyCandidate(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions) (TidyApplyResult, error) {
|
||||
base := TidyApplyResult{
|
||||
CandidateID: cand.ID,
|
||||
Action: cand.Action,
|
||||
TargetID: cand.TargetID,
|
||||
AffectedIDs: []string{},
|
||||
}
|
||||
if !cand.Action.applyable() {
|
||||
// link / keep 没有执行语义(design/23 §14):如实说「不可应用」,不写任何行。
|
||||
return skipTidyApply(base, tidySkipActionNotApplyable), nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
switch cand.Action {
|
||||
case TidyMerge:
|
||||
return s.applyTidyMerge(ctx, cand, opts, now)
|
||||
case TidyArchive:
|
||||
return s.applyTidyArchive(ctx, cand, opts, now)
|
||||
case TidyReclassify:
|
||||
return s.applyTidyReclassify(ctx, cand, opts, now)
|
||||
default:
|
||||
return skipTidyApply(base, tidySkipActionNotApplyable), nil
|
||||
}
|
||||
}
|
||||
|
||||
// applyable 报告一个动作是否有执行语义。
|
||||
func (a TidyAction) applyable() bool {
|
||||
switch a {
|
||||
case TidyMerge, TidyArchive, TidyReclassify:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// applyTidyMerge 把候选指认的「被吸收行」软合并进「存活行」。
|
||||
func (s *Store) applyTidyMerge(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
|
||||
base := TidyApplyResult{
|
||||
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
|
||||
}
|
||||
if len(cand.SourceIDs) != 1 || strings.TrimSpace(cand.TargetID) == "" {
|
||||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||||
}
|
||||
absorbed, survivor, skip, err := s.loadTidyMergeRows(ctx, cand)
|
||||
if err != nil {
|
||||
return base, err
|
||||
}
|
||||
if skip != "" {
|
||||
return skipTidyApply(base, skip), nil
|
||||
}
|
||||
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
|
||||
if err := markTidyAbsorbed(ctx, tx, absorbed.ID, survivor.ID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return bumpSurvivor(ctx, tx, survivor, absorbed, now)
|
||||
})
|
||||
return finishTidyApply(base, []string{absorbed.ID}, err, tidySkipSourceNotActive)
|
||||
}
|
||||
|
||||
// applyTidyArchive 软删一条一次性事件(status='archived'):行保留、内容不改,
|
||||
// 只是不再参与检索与整理(design/23 §10 的 status 列 + §8 的软删除口径)。
|
||||
func (s *Store) applyTidyArchive(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
|
||||
base := TidyApplyResult{
|
||||
CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{},
|
||||
}
|
||||
if len(cand.SourceIDs) != 1 {
|
||||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||||
}
|
||||
item, err := s.Get(ctx, cand.SourceIDs[0])
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return skipTidyApply(base, tidySkipSourceMissing), nil
|
||||
}
|
||||
if err != nil {
|
||||
return base, err
|
||||
}
|
||||
if skip := tidyPartitionMismatch(cand, item); skip != "" {
|
||||
return skipTidyApply(base, skip), nil
|
||||
}
|
||||
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
|
||||
return archiveTidyItem(ctx, tx, item.ID, now)
|
||||
})
|
||||
return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive)
|
||||
}
|
||||
|
||||
// applyTidyReclassify 修正 kind 误分类(design/23 §5.1 T0-6):只改 kind 一列,
|
||||
// 建议值来自候选证据里的 ProposedKind。
|
||||
func (s *Store) applyTidyReclassify(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions, now time.Time) (TidyApplyResult, error) {
|
||||
base := TidyApplyResult{
|
||||
CandidateID: cand.ID, Action: cand.Action, AffectedIDs: []string{},
|
||||
}
|
||||
if len(cand.SourceIDs) != 1 {
|
||||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||||
}
|
||||
kind := Kind(cand.Evidence.ProposedKind)
|
||||
if !kind.valid() {
|
||||
return skipTidyApply(base, tidySkipInvalidCandidate), nil
|
||||
}
|
||||
item, err := s.Get(ctx, cand.SourceIDs[0])
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return skipTidyApply(base, tidySkipSourceMissing), nil
|
||||
}
|
||||
if err != nil {
|
||||
return base, err
|
||||
}
|
||||
if skip := tidyPartitionMismatch(cand, item); skip != "" {
|
||||
return skipTidyApply(base, skip), nil
|
||||
}
|
||||
if item.Kind == kind {
|
||||
// 已经是建议值:没有可改的,如实说「源行不在册」的口径不适用,这里
|
||||
// 用 already_decided 更像「无需再做」。
|
||||
return skipTidyApply(base, tidySkipAlreadyDecided), nil
|
||||
}
|
||||
err = s.tidyApplyTx(ctx, cand, opts, now, func(ctx context.Context, tx *sql.Tx) error {
|
||||
return reclassifyTidyItem(ctx, tx, item.ID, kind, now)
|
||||
})
|
||||
return finishTidyApply(base, []string{item.ID}, err, tidySkipSourceNotActive)
|
||||
}
|
||||
|
||||
// loadTidyMergeRows 读取软合并的两行并做分区硬门校验。skip 非空表示应如实跳过
|
||||
// (原因由它给出),此时两行不可用。
|
||||
func (s *Store) loadTidyMergeRows(ctx context.Context, cand TidyCandidate) (MemoryItem, MemoryItem, string, error) {
|
||||
absorbed, err := s.Get(ctx, cand.SourceIDs[0])
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return MemoryItem{}, MemoryItem{}, tidySkipSourceMissing, nil
|
||||
}
|
||||
if err != nil {
|
||||
return MemoryItem{}, MemoryItem{}, "", err
|
||||
}
|
||||
survivor, err := s.Get(ctx, cand.TargetID)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return MemoryItem{}, MemoryItem{}, tidySkipSourceMissing, nil
|
||||
}
|
||||
if err != nil {
|
||||
return MemoryItem{}, MemoryItem{}, "", err
|
||||
}
|
||||
if skip := tidyPartitionMismatch(cand, absorbed); skip != "" {
|
||||
return MemoryItem{}, MemoryItem{}, skip, nil
|
||||
}
|
||||
if skip := tidyPartitionMismatch(cand, survivor); skip != "" {
|
||||
return MemoryItem{}, MemoryItem{}, skip, nil
|
||||
}
|
||||
if absorbed.ID == survivor.ID {
|
||||
return MemoryItem{}, MemoryItem{}, tidySkipInvalidCandidate, nil
|
||||
}
|
||||
return absorbed, survivor, "", nil
|
||||
}
|
||||
|
||||
// tidyPartitionMismatch 报告候选与一条记忆是否不同分区(是 ⇒ 返回不匹配原因)。
|
||||
// 分区键是 design/23 §5.2-7/8 的硬门,应用侧必须再核一次:候选行是落库数据,
|
||||
// 而落库数据可能来自更早的规则版本或手工改动。
|
||||
func tidyPartitionMismatch(cand TidyCandidate, item MemoryItem) string {
|
||||
if cand.PersonaID != item.PersonaID || cand.Scope != item.Scope || cand.ProjectID != item.ProjectID {
|
||||
return tidySkipPartitionMismatch
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// RevertTidyCandidate 撤销一条**已应用**的候选(design/23 §8 的回滚 + §11 的
|
||||
// candidates/{id}/revert)。它把记忆行复原并把候选退回 pending(于是可以重新决定),
|
||||
// 全程不删任何一行。候选不在已应用状态时返回 skipped/not_applied。
|
||||
func (s *Store) RevertTidyCandidate(ctx context.Context, id string, opts TidyApplyOptions) (TidyApplyResult, error) {
|
||||
cand, err := s.GetTidyCandidate(ctx, id)
|
||||
if err != nil {
|
||||
return TidyApplyResult{}, err
|
||||
}
|
||||
base := TidyApplyResult{
|
||||
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID, AffectedIDs: []string{},
|
||||
}
|
||||
if !cand.Action.applyable() {
|
||||
return skipTidyApply(base, tidySkipActionNotApplyable), nil
|
||||
}
|
||||
if cand.Status != tidyCandidateApplied {
|
||||
return skipTidyApply(base, tidySkipNotApplied), nil
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
var reverted []string
|
||||
err = s.tidyRevertTx(ctx, cand, opts, func(ctx context.Context, tx *sql.Tx) error {
|
||||
out, err := revertTidyAction(ctx, tx, cand, now)
|
||||
reverted = out
|
||||
return err
|
||||
})
|
||||
if errors.Is(err, errTidyCandidateDecided) {
|
||||
return skipTidyApply(base, tidySkipNotApplied), nil
|
||||
}
|
||||
if err != nil {
|
||||
return tidyFailed(base, err), nil
|
||||
}
|
||||
base.Status = TidyApplyApplied
|
||||
base.AffectedIDs = reverted
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// ApplyTidyCandidates 批量应用待定候选(design/23 §11 的 apply 粒度之二/之三):
|
||||
// runID 非空 ⇒ 只应用该轮产出的候选(「按 run 批量应用」);runID 为空 ⇒
|
||||
// 应用全部待定候选(「全部待定应用」)。上限由 opts.Limit 决定,超限如实回报
|
||||
// Truncated + Total(绝不静默少应用)。
|
||||
//
|
||||
// 逐条结果都在 Results 里(失败隔离:一条失败不阻塞其余)。
|
||||
func (s *Store) ApplyTidyCandidates(ctx context.Context, runID string, opts TidyApplyOptions) (TidyApplyBatch, error) {
|
||||
limit := opts.normalizedLimit()
|
||||
if strings.TrimSpace(runID) != "" {
|
||||
if _, err := s.GetTidyRun(ctx, runID); err != nil {
|
||||
return TidyApplyBatch{}, fmt.Errorf("memory/tidy: apply by run %s: %w", runID, err)
|
||||
}
|
||||
}
|
||||
total, err := s.countTidyCandidates(ctx, tidyCandidatePending, runID)
|
||||
if err != nil {
|
||||
return TidyApplyBatch{}, err
|
||||
}
|
||||
cands, err := s.listTidyCandidates(ctx, tidyCandidatePending, runID, limit)
|
||||
if err != nil {
|
||||
return TidyApplyBatch{}, err
|
||||
}
|
||||
batch := TidyApplyBatch{
|
||||
RunID: runID,
|
||||
Limit: limit,
|
||||
Total: total,
|
||||
Truncated: total > len(cands),
|
||||
Results: make([]TidyApplyResult, 0, len(cands)),
|
||||
}
|
||||
for _, cand := range cands {
|
||||
if err := ctx.Err(); err != nil {
|
||||
// 上下文取消时**已应用的保持已应用**,未做的留给下一次(与清扫同口径)。
|
||||
break
|
||||
}
|
||||
res, err := s.applyCandidate(ctx, cand, opts)
|
||||
if err != nil {
|
||||
res = tidyFailed(TidyApplyResult{
|
||||
CandidateID: cand.ID, Action: cand.Action, TargetID: cand.TargetID,
|
||||
AffectedIDs: []string{},
|
||||
}, err)
|
||||
}
|
||||
batch.Results = append(batch.Results, res)
|
||||
tallyTidyApply(&batch, res)
|
||||
}
|
||||
if batch.RunID != "" && batch.Applied > 0 {
|
||||
// 该轮的 applied 计数累加(design/23 §10 的 memory_tidy_runs.applied):
|
||||
// 运行时那一轮写 0(当轮绝不自动应用),事后应用在这里补记。
|
||||
if err := s.addTidyRunApplied(ctx, batch.RunID, batch.Applied); err != nil {
|
||||
return batch, err
|
||||
}
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
// tallyTidyApply 把一条结果计入批量回执的计数器。
|
||||
func tallyTidyApply(batch *TidyApplyBatch, res TidyApplyResult) {
|
||||
switch res.Status {
|
||||
case TidyApplyApplied:
|
||||
batch.Applied++
|
||||
switch res.Action {
|
||||
case TidyMerge:
|
||||
batch.Merged++
|
||||
case TidyArchive:
|
||||
batch.Archived++
|
||||
case TidyReclassify:
|
||||
batch.Reclassified++
|
||||
}
|
||||
case TidyApplyFailed:
|
||||
batch.Failed++
|
||||
default:
|
||||
batch.Skipped++
|
||||
}
|
||||
}
|
||||
|
||||
// skipTidyApply 造一条 skipped 结果。
|
||||
func skipTidyApply(base TidyApplyResult, reason string) TidyApplyResult {
|
||||
base.Status = TidyApplySkipped
|
||||
base.Reason = reason
|
||||
if base.AffectedIDs == nil {
|
||||
base.AffectedIDs = []string{}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// tidyFailed 造一条 failed 结果(错误只进 Error 字段,调用方决定记不记日志)。
|
||||
func tidyFailed(base TidyApplyResult, err error) TidyApplyResult {
|
||||
base.Status = TidyApplyFailed
|
||||
base.Reason = "apply_failed"
|
||||
base.Error = err.Error()
|
||||
if base.AffectedIDs == nil {
|
||||
base.AffectedIDs = []string{}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
// finishTidyApply 把事务结果收敛成一条结果:nil ⇒ applied;幂等哨兵 ⇒ skipped;
|
||||
// 目标行不在册 ⇒ skipped(原因由 notActiveReason 给);其余 ⇒ failed。
|
||||
//
|
||||
// affected 是**成功时才会挂上去**的受影响行 id:跳过的结果绝不能声称改过哪一行
|
||||
// (那是把「没做」说成「做了」)。
|
||||
func finishTidyApply(base TidyApplyResult, affected []string, err error, notActiveReason string) (TidyApplyResult, error) {
|
||||
switch {
|
||||
case err == nil:
|
||||
base.Status = TidyApplyApplied
|
||||
base.AffectedIDs = affected
|
||||
return base, nil
|
||||
case errors.Is(err, errTidyCandidateDecided):
|
||||
return skipTidyApply(base, tidySkipAlreadyDecided), nil
|
||||
case errors.Is(err, errTidyItemNotActive):
|
||||
return skipTidyApply(base, notActiveReason), nil
|
||||
default:
|
||||
return tidyFailed(base, err), nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件是记忆整理**应用路径**(切片 TIDY-1)的存储层:候选认领、软合并 /
|
||||
// 软删 / 改 kind 的事务写入,以及回滚。它复用 memory 包既有的 *sql.DB、
|
||||
// 分区口径(tidyPartition / tidyStatusActive)与软合并且已存在的辅助
|
||||
// (preinsert_store.go 的 bumpSurvivor / dropSurvivorLink / revertMergedSQL /
|
||||
// selectMergedIntoSQL —— 那是 design/23 §8 的**同一套**软合并列语义,不是第二套)。
|
||||
//
|
||||
// 与 D3(入库前审查)那套的关系:语义同源(status/merged_into/merged_from/
|
||||
// merge_count),但**闸口不同** —— D3 的顺序队列保证一条记忆只审一次,因此它的
|
||||
// markAbsorbed 不带 `AND status='active'`;整理的候选是**事后**执行的,可能
|
||||
// 过时(源行已被别的动作改过),所以这里每一条 UPDATE 都带状态条件,并把它
|
||||
// 当作幂等/过时的判据(RowsAffected == 0)。
|
||||
|
||||
const (
|
||||
// tidyCandidateApplied 是候选「已应用」状态(design/23 §10 的 status 闭集)。
|
||||
tidyCandidateApplied = "applied"
|
||||
|
||||
// tidyStatusArchived 是软删后的行状态(design/23 §10:active/merged/archived)。
|
||||
// 归档行不再参与检索与整理,但**行与原文都还在**,因此可 revert(§8)。
|
||||
tidyStatusArchived = "archived"
|
||||
)
|
||||
|
||||
// claimTidyCandidateSQL 是**执行闸**:只有把候选从 pending 原子地推到
|
||||
// applied 的那一次调用才继续往下改记忆。WHERE 里的状态条件就是幂等性的根,
|
||||
// 因此它绝不能省(少了它,重复应用会再写一次 memory_items)。
|
||||
const claimTidyCandidateSQL = `UPDATE memory_tidy_candidates
|
||||
SET status = ?, decided_at = ?, decided_by = ? WHERE id = ? AND status = ?`
|
||||
|
||||
// markTidyAbsorbedSQL 标记被吸收行:status='merged' + merged_into=存活 id。
|
||||
// `AND status='active'` 是第二道闸:源行已被别的动作改过时 RowsAffected==0,
|
||||
// 调用方据此回滚整个事务(不产生第二次合并计数)。
|
||||
const markTidyAbsorbedSQL = `UPDATE memory_items
|
||||
SET status = ?, merged_into = ?, updated_at = ? WHERE id = ? AND status = ?`
|
||||
|
||||
// setTidyItemStatusSQL 是软删 / 复原共用的一条:只改 status 与 updated_at,
|
||||
// 带 from 状态条件。
|
||||
const setTidyItemStatusSQL = `UPDATE memory_items
|
||||
SET status = ?, updated_at = ? WHERE id = ? AND status = ?`
|
||||
|
||||
// reclassifyTidyItemSQL 只改 kind 一列(design/23 §5.1 T0-6),带状态条件。
|
||||
const reclassifyTidyItemSQL = `UPDATE memory_items
|
||||
SET kind = ?, updated_at = ? WHERE id = ? AND status = ?`
|
||||
|
||||
// undoTidyReclassifySQL 撤回一次 kind 修正:只在当前 kind 仍是当时写的建议值、
|
||||
// 且行仍在册时生效(否则说明有人后来手动改过,不该被回滚覆盖)。
|
||||
const undoTidyReclassifySQL = `UPDATE memory_items
|
||||
SET kind = ?, updated_at = ? WHERE id = ? AND status = ? AND kind = ?`
|
||||
|
||||
// tidyApplyTx 在**一个事务**里跑一次应用:先认领候选(条件更新 = 执行闸),
|
||||
// 再执行 mutate,最后提交。任何一步失败都回滚 —— 因此「候选被标 applied 但
|
||||
// 记忆没改」这种半成品状态不可能落库。
|
||||
func (s *Store) tidyApplyTx(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions,
|
||||
now time.Time, mutate func(context.Context, *sql.Tx) error) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory/tidy: apply candidate begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
claimed, err := claimTidyCandidate(ctx, tx, cand.ID, tidyCandidatePending, tidyCandidateApplied, opts.Actor, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
return errTidyCandidateDecided
|
||||
}
|
||||
if err := mutate(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("memory/tidy: apply candidate commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tidyRevertTx 与 tidyApplyTx 同构,只是认领的方向相反(applied → pending)。
|
||||
func (s *Store) tidyRevertTx(ctx context.Context, cand TidyCandidate, opts TidyApplyOptions,
|
||||
mutate func(context.Context, *sql.Tx) error) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory/tidy: revert candidate begin: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
claimed, err := claimTidyCandidate(ctx, tx, cand.ID, tidyCandidateApplied, tidyCandidatePending, opts.Actor, time.Now().UTC())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !claimed {
|
||||
return errTidyCandidateDecided
|
||||
}
|
||||
if err := mutate(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("memory/tidy: revert candidate commit: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// claimTidyCandidate 原子地把候选从**当前状态 from** 推到 to。返回是否真的改了行:
|
||||
// false 表示它已经被决定过(幂等命中,不是错误)。
|
||||
//
|
||||
// from 参数是这道闸的关键:应用要求 from='pending',回滚要求 from='applied'。
|
||||
// 两者共用一个语句,但**不能共用一个硬编码的 from** —— 那会让回滚永远匹配不到行。
|
||||
func claimTidyCandidate(ctx context.Context, tx *sql.Tx, id, from, to, actor string, now time.Time) (bool, error) {
|
||||
res, err := tx.ExecContext(ctx, claimTidyCandidateSQL, to, formatTime(now), actor, id, from)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("memory/tidy: claim candidate %s: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("memory/tidy: claim candidate %s rows: %w", id, err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// markTidyAbsorbed 标记被吸收行并刷新存活行的合并元信息。源行不在册时返回
|
||||
// errTidyItemNotActive(事务因此回滚)。
|
||||
func markTidyAbsorbed(ctx context.Context, tx *sql.Tx, absorbedID, survivorID string, now time.Time) error {
|
||||
res, err := tx.ExecContext(ctx, markTidyAbsorbedSQL,
|
||||
string(ingestStatusMerged), survivorID, formatTime(now), absorbedID, tidyStatusActive)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory/tidy: mark absorbed item: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory/tidy: mark absorbed item rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return errTidyItemNotActive
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setTidyItemStatus 在 from → to 之间改一行的 status。返回是否真的改了行。
|
||||
func setTidyItemStatus(ctx context.Context, tx *sql.Tx, id, from, to string, now time.Time) (bool, error) {
|
||||
res, err := tx.ExecContext(ctx, setTidyItemStatusSQL, to, formatTime(now), id, from)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("memory/tidy: set item status %s: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("memory/tidy: set item status rows: %w", err)
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
||||
// archiveTidyItem 把一条在册记忆软删(active → archived)。不在册时返回
|
||||
// errTidyItemNotActive。它**只改 status/updated_at**,绝不碰 content。
|
||||
func archiveTidyItem(ctx context.Context, tx *sql.Tx, id string, now time.Time) error {
|
||||
changed, err := setTidyItemStatus(ctx, tx, id, tidyStatusActive, tidyStatusArchived, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !changed {
|
||||
return errTidyItemNotActive
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reclassifyTidyItem 把一条在册记忆的 kind 改为 kind。不在册时返回
|
||||
// errTidyItemNotActive。
|
||||
func reclassifyTidyItem(ctx context.Context, tx *sql.Tx, id string, kind Kind, now time.Time) error {
|
||||
res, err := tx.ExecContext(ctx, reclassifyTidyItemSQL, string(kind), formatTime(now), id, tidyStatusActive)
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory/tidy: reclassify item %s: %w", id, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("memory/tidy: reclassify item rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return errTidyItemNotActive
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addTidyRunApplied 把该轮留痕的 applied 计数累加(design/23 §10)。
|
||||
func (s *Store) addTidyRunApplied(ctx context.Context, runID string, applied int) error {
|
||||
if _, err := s.db.ExecContext(ctx, incrementTidyRunAppliedSQL, applied, runID); err != nil {
|
||||
return fmt.Errorf("memory/tidy: bump run applied %s: %w", runID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// revertTidyAction 在事务里撤销一条已应用候选对记忆的改动,返回真正被复原的
|
||||
// item id(一条都没复原时返回空切片 —— 那说明用户已经用 /memories/{id}/revert
|
||||
// 手工撤过了,此时只把候选退回 pending,不假装改过东西)。
|
||||
func revertTidyAction(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
|
||||
switch cand.Action {
|
||||
case TidyMerge:
|
||||
return revertTidyMerge(ctx, tx, cand, now)
|
||||
case TidyArchive:
|
||||
return revertTidyArchive(ctx, tx, cand, now)
|
||||
case TidyReclassify:
|
||||
return revertTidyReclassify(ctx, tx, cand, now)
|
||||
default:
|
||||
return []string{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// revertTidyMerge 复原一次软合并:逐条把被吸收行从 merged 复原为 active,并从
|
||||
// 存活行的 merged_from 里摘掉它(dropSurvivorLink)。只复原**这条候选做的那次**
|
||||
// 合并(merged_into 必须等于候选的 target)。
|
||||
func revertTidyMerge(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
|
||||
out := make([]string, 0, len(cand.SourceIDs))
|
||||
for _, id := range cand.SourceIDs {
|
||||
ok, err := revertTidyMergeInTx(ctx, tx, id, cand.TargetID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// revertTidyMergeInTx 复原单条被吸收行。它只认 `status='merged'` 且
|
||||
// `merged_into=<该候选的存活 id>` 的行;targetID 为空时接受任意存活 id
|
||||
// (容错:老候选可能没落 target)。
|
||||
func revertTidyMergeInTx(ctx context.Context, tx *sql.Tx, absorbedID, targetID string, now time.Time) (bool, error) {
|
||||
var survivorID string
|
||||
err := tx.QueryRowContext(ctx, selectMergedIntoSQL, absorbedID, string(ingestStatusMerged)).Scan(&survivorID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("memory/tidy: read merged item %s: %w", absorbedID, err)
|
||||
}
|
||||
if targetID != "" && survivorID != targetID {
|
||||
// 它现在挂在**别的**存活行上(后来又被合并过一次):这条候选的回滚
|
||||
// 不该把它从新关系里拽出来。
|
||||
return false, nil
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, revertMergedSQL,
|
||||
string(tidyStatusActive), formatTime(now), absorbedID, string(ingestStatusMerged)); err != nil {
|
||||
return false, fmt.Errorf("memory/tidy: restore merged item %s: %w", absorbedID, err)
|
||||
}
|
||||
if survivorID != "" {
|
||||
if err := dropSurvivorLink(ctx, tx, survivorID, absorbedID, now); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// revertTidyArchive 复原被软删(archived)的行:archived → active。
|
||||
func revertTidyArchive(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
|
||||
out := make([]string, 0, len(cand.SourceIDs))
|
||||
for _, id := range cand.SourceIDs {
|
||||
changed, err := setTidyItemStatus(ctx, tx, id, tidyStatusArchived, tidyStatusActive, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if changed {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// revertTidyReclassify 撤回一次 kind 修正:只在当前 kind 仍等于当时写的建议值、
|
||||
// 且行仍在册时生效(有人后来手动改过就保持不动)。旧 kind 来自候选证据 KindA。
|
||||
func revertTidyReclassify(ctx context.Context, tx *sql.Tx, cand TidyCandidate, now time.Time) ([]string, error) {
|
||||
kind := Kind(cand.Evidence.ProposedKind)
|
||||
previous := Kind(cand.Evidence.KindA)
|
||||
if !previous.valid() || !kind.valid() || len(cand.SourceIDs) != 1 {
|
||||
return []string{}, nil
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, undoTidyReclassifySQL,
|
||||
string(previous), formatTime(now), cand.SourceIDs[0], tidyStatusActive, string(kind))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory/tidy: undo reclassify %s: %w", cand.SourceIDs[0], err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory/tidy: undo reclassify rows: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return []string{}, nil
|
||||
}
|
||||
return []string{cand.SourceIDs[0]}, nil
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/embedding"
|
||||
)
|
||||
|
||||
// 本文件锁定记忆整理**应用路径**(切片 TIDY-1;design/23 §7/§8)的六条判据:
|
||||
//
|
||||
// ① merge = 软合并:被吸收行 status='merged' + merged_into,存活行
|
||||
// merged_from/merge_count 更新,**content 逐字不变**,memory_items 总行数不变;
|
||||
// ② **幂等**:重复应用同一条候选 ⇒ skipped/already_decided,且不产生第二次副作用
|
||||
// (merge_count 不再涨、没有第二行被改);
|
||||
// ③ **可 revert**:回滚后字段复原(status/merged_into/merged_from/merge_count/kind),
|
||||
// 行数不变,候选退回 pending;
|
||||
// ④ archive = 软删(status='archived'),reclassify = 改 kind,都不碰 content;
|
||||
// ⑤ link 不可应用 ⇒ skipped/action_not_applyable,零改动;
|
||||
// ⑥ 批量(按 run)有上限、逐条结果、超限如实报 Truncated + Total。
|
||||
|
||||
// tidyItemRow 是断言用的一行记忆关键字段(含正文,用来钉「绝不改 content」)。
|
||||
type tidyItemRow struct {
|
||||
status string
|
||||
mergedInto string
|
||||
mergedFrom string
|
||||
mergeCount int
|
||||
content string
|
||||
kind string
|
||||
}
|
||||
|
||||
// loadTidyItemRow 读一行记忆的整理列 + 正文。
|
||||
func loadTidyItemRow(t *testing.T, store *Store, id string) tidyItemRow {
|
||||
t.Helper()
|
||||
var r tidyItemRow
|
||||
err := store.db.QueryRowContext(context.Background(),
|
||||
`SELECT status, merged_into, merged_from, merge_count, content, kind
|
||||
FROM memory_items WHERE id = ?`, id).
|
||||
Scan(&r.status, &r.mergedInto, &r.mergedFrom, &r.mergeCount, &r.content, &r.kind)
|
||||
if err != nil {
|
||||
t.Fatalf("读记忆行 %s: %v", id, err)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// countMemoryItems 数 memory_items 的总行数(软删除 ⇒ 应用前后必须不变)。
|
||||
func countMemoryItems(t *testing.T, store *Store) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := store.db.QueryRowContext(context.Background(),
|
||||
`SELECT COUNT(*) FROM memory_items`).Scan(&n); err != nil {
|
||||
t.Fatalf("数 memory_items: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// mergeCandidateFromRun 跑一轮整理并返回唯一的 merge 候选(顺带把运行 id 带出来)。
|
||||
func mergeCandidateFromRun(t *testing.T, store *Store, emb embedding.Embedder) TidyCandidate {
|
||||
t.Helper()
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), emb)
|
||||
stats, err := tr.RunOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
cands, err := store.ListTidyCandidates(context.Background(), "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
}
|
||||
merges := candidatesByAction(cands, TidyMerge)
|
||||
if len(merges) != 1 {
|
||||
t.Fatalf("merge 候选 = %d, want 1(本轮 run=%s)", len(merges), stats.RunID)
|
||||
}
|
||||
if merges[0].RunID != stats.RunID {
|
||||
t.Fatalf("候选 run_id = %q, want 本轮 run_id = %q", merges[0].RunID, stats.RunID)
|
||||
}
|
||||
return merges[0]
|
||||
}
|
||||
|
||||
// TestTidyApplyMergeIsSoftIdempotentAndRevertible 是①②③三条判据的合并用例:
|
||||
// 软合并 → 重复应用无副作用 → 回滚后字段复原。
|
||||
func TestTidyApplyMergeIsSoftIdempotentAndRevertible(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
short := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
|
||||
long := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||||
|
||||
cand := mergeCandidateFromRun(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
fixtureHabitShort: {1, 0},
|
||||
fixtureHabitLong: simVector(0.95),
|
||||
}})
|
||||
if cand.SourceIDs[0] != short.ID || cand.TargetID != long.ID {
|
||||
t.Fatalf("候选方向不对:sources=%v target=%q", cand.SourceIDs, cand.TargetID)
|
||||
}
|
||||
rowsBefore := countMemoryItems(t, store)
|
||||
shortBefore := loadTidyItemRow(t, store, short.ID)
|
||||
longBefore := loadTidyItemRow(t, store, long.ID)
|
||||
|
||||
res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTidyCandidate: %v", err)
|
||||
}
|
||||
if res.Status != TidyApplyApplied {
|
||||
t.Fatalf("应用结果 = %+v, want applied", res)
|
||||
}
|
||||
|
||||
absorbed := loadTidyItemRow(t, store, short.ID)
|
||||
if absorbed.status != ingestStatusMerged || absorbed.mergedInto != long.ID {
|
||||
t.Fatalf("被吸收行 = %+v, want status=merged merged_into=%s", absorbed, long.ID)
|
||||
}
|
||||
if absorbed.content != shortBefore.content {
|
||||
t.Fatalf("被吸收行 content 被改了:%q → %q", shortBefore.content, absorbed.content)
|
||||
}
|
||||
survivor := loadTidyItemRow(t, store, long.ID)
|
||||
if survivor.content != longBefore.content {
|
||||
t.Fatalf("存活行 content 被改了:%q → %q", longBefore.content, survivor.content)
|
||||
}
|
||||
if survivor.mergeCount != longBefore.mergeCount+1 {
|
||||
t.Fatalf("存活行 merge_count = %d, want %d", survivor.mergeCount, longBefore.mergeCount+1)
|
||||
}
|
||||
if !jsonHasID(t, survivor.mergedFrom, short.ID) {
|
||||
t.Fatalf("存活行 merged_from = %s, want 含 %s", survivor.mergedFrom, short.ID)
|
||||
}
|
||||
if got := countMemoryItems(t, store); got != rowsBefore {
|
||||
t.Fatalf("软合并改变了行数:%d → %d(行永不物理删除)", rowsBefore, got)
|
||||
}
|
||||
applied, err := store.GetTidyCandidate(ctx, cand.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetTidyCandidate: %v", err)
|
||||
}
|
||||
if applied.Status != tidyCandidateApplied {
|
||||
t.Fatalf("候选状态 = %q, want %q", applied.Status, tidyCandidateApplied)
|
||||
}
|
||||
|
||||
// ② 重复应用:skipped/already_decided,且**零副作用**。
|
||||
again, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
|
||||
if err != nil {
|
||||
t.Fatalf("第二次 ApplyTidyCandidate 不该报错(幂等命中不是错误):%v", err)
|
||||
}
|
||||
if again.Status != TidyApplySkipped || again.Reason != tidySkipAlreadyDecided {
|
||||
t.Fatalf("第二次应用 = %+v, want skipped/%s", again, tidySkipAlreadyDecided)
|
||||
}
|
||||
if len(again.AffectedIDs) != 0 {
|
||||
t.Fatalf("跳过的结果不得声称改过行:%v", again.AffectedIDs)
|
||||
}
|
||||
if got := loadTidyItemRow(t, store, long.ID).mergeCount; got != survivor.mergeCount {
|
||||
t.Fatalf("重复应用把 merge_count 又加了一次:%d → %d", survivor.mergeCount, got)
|
||||
}
|
||||
if got := countMemoryItems(t, store); got != rowsBefore {
|
||||
t.Fatalf("重复应用改变了行数:%d → %d", rowsBefore, got)
|
||||
}
|
||||
|
||||
// ③ 回滚:字段逐字复原(updated_at 除外 —— 它是「什么时候动过」的审计面)。
|
||||
rev, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"})
|
||||
if err != nil {
|
||||
t.Fatalf("RevertTidyCandidate: %v", err)
|
||||
}
|
||||
if rev.Status != TidyApplyApplied {
|
||||
t.Fatalf("回滚结果 = %+v, want applied", rev)
|
||||
}
|
||||
if got := loadTidyItemRow(t, store, short.ID); got != shortBefore {
|
||||
t.Fatalf("回滚后被吸收行未复原:\n got=%+v\nwant=%+v", got, shortBefore)
|
||||
}
|
||||
if got := loadTidyItemRow(t, store, long.ID); got.mergeCount != longBefore.mergeCount || got.mergedFrom != "[]" {
|
||||
t.Fatalf("回滚后存活行合并元信息未复原:%+v(want merge_count=%d merged_from=[])", got, longBefore.mergeCount)
|
||||
}
|
||||
if got := countMemoryItems(t, store); got != rowsBefore {
|
||||
t.Fatalf("回滚改变了行数:%d → %d", rowsBefore, got)
|
||||
}
|
||||
if back, err := store.GetTidyCandidate(ctx, cand.ID); err != nil || back.Status != tidyCandidatePending {
|
||||
t.Fatalf("回滚后候选状态 = %q (err=%v), want pending", back.Status, err)
|
||||
}
|
||||
// 回滚是幂等的:再回一次 ⇒ skipped/not_applied。
|
||||
if againRev, err := store.RevertTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
|
||||
t.Fatalf("第二次回滚不该报错:%v", err)
|
||||
} else if againRev.Status != TidyApplySkipped || againRev.Reason != tidySkipNotApplied {
|
||||
t.Fatalf("第二次回滚 = %+v, want skipped/%s", againRev, tidySkipNotApplied)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyApplyMergeUnderConcurrencyHasSingleWinner 钉住执行闸(候选状态的条件
|
||||
// 更新)在**并发**下也只有一次生效:16 路同时应用同一条候选,恰好一条 applied,
|
||||
// merge_count 只涨一次。少了这道闸,多路会各自把源行当作 active 再合并一次。
|
||||
func TestTidyApplyMergeUnderConcurrencyHasSingleWinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
short := seedTidyItem(t, store, habitItem(fixtureHabitShort, base, 0.9))
|
||||
long := seedTidyItem(t, store, habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9))
|
||||
cand := mergeCandidateFromRun(t, store, stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
fixtureHabitShort: {1, 0},
|
||||
fixtureHabitLong: simVector(0.95),
|
||||
}})
|
||||
|
||||
const racers = 16
|
||||
var (
|
||||
mu sync.Mutex
|
||||
applied int
|
||||
skipped int
|
||||
start = make(chan struct{})
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
for i := 0; i < racers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
res, err := store.ApplyTidyCandidate(ctx, cand.ID, TidyApplyOptions{Actor: "owner:race"})
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
switch {
|
||||
case err != nil:
|
||||
t.Errorf("并发应用报错:%v", err)
|
||||
case res.Status == TidyApplyApplied:
|
||||
applied++
|
||||
default:
|
||||
skipped++
|
||||
}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
if applied != 1 || skipped != racers-1 {
|
||||
t.Fatalf("并发应用 applied=%d skipped=%d, want 1/%d", applied, skipped, racers-1)
|
||||
}
|
||||
if got := loadTidyItemRow(t, store, long.ID).mergeCount; got != 2 {
|
||||
t.Fatalf("存活行 merge_count = %d, want 2(只允许一次吸收)", got)
|
||||
}
|
||||
if got := loadTidyItemRow(t, store, short.ID); got.status != ingestStatusMerged || got.mergedInto != long.ID {
|
||||
t.Fatalf("被吸收行 = %+v, want merged into %s", got, long.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyApplyArchiveAndRevert 钉住④:archive 是软删(status='archived',内容不变、
|
||||
// 行数不变),revert 复原为 active。
|
||||
func TestTidyApplyArchiveAndRevert(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
oneOff := seedTidyItem(t, store, habitItem(fixtureOneOffEvent, base, 0.9))
|
||||
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), nil)
|
||||
if _, err := tr.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
}
|
||||
archives := candidatesByAction(cands, TidyArchive)
|
||||
if len(archives) != 1 {
|
||||
t.Fatalf("archive 候选 = %d, want 1(%+v)", len(archives), cands)
|
||||
}
|
||||
rowsBefore := countMemoryItems(t, store)
|
||||
before := loadTidyItemRow(t, store, oneOff.ID)
|
||||
|
||||
if res, err := store.ApplyTidyCandidate(ctx, archives[0].ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
|
||||
t.Fatalf("ApplyTidyCandidate: %v", err)
|
||||
} else if res.Status != TidyApplyApplied {
|
||||
t.Fatalf("archive 应用 = %+v, want applied", res)
|
||||
}
|
||||
archived := loadTidyItemRow(t, store, oneOff.ID)
|
||||
if archived.status != tidyStatusArchived {
|
||||
t.Fatalf("归档后 status = %q, want %q", archived.status, tidyStatusArchived)
|
||||
}
|
||||
if archived.content != before.content {
|
||||
t.Fatalf("软删改了 content:%q → %q", before.content, archived.content)
|
||||
}
|
||||
if got := countMemoryItems(t, store); got != rowsBefore {
|
||||
t.Fatalf("软删改变了行数:%d → %d(design/23 §8:行保留)", rowsBefore, got)
|
||||
}
|
||||
|
||||
if res, err := store.RevertTidyCandidate(ctx, archives[0].ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
|
||||
t.Fatalf("RevertTidyCandidate: %v", err)
|
||||
} else if res.Status != TidyApplyApplied {
|
||||
t.Fatalf("archive 回滚 = %+v, want applied", res)
|
||||
}
|
||||
if got := loadTidyItemRow(t, store, oneOff.ID); got != before {
|
||||
t.Fatalf("回滚后未复原:\n got=%+v\nwant=%+v", got, before)
|
||||
}
|
||||
if got := countMemoryItems(t, store); got != rowsBefore {
|
||||
t.Fatalf("回滚改变了行数:%d → %d", rowsBefore, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyApplyReclassifyAndRevert 钉住④的另一半:reclassify 只改 kind 一列,
|
||||
// 内容不变;revert 把 kind 还原成候选证据里的旧值。
|
||||
func TestTidyApplyReclassifyAndRevert(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
misclassified := MemoryItem{
|
||||
PersonaID: "persona.a", Scope: ScopePersona, Kind: KindWorldEvent,
|
||||
Content: fixtureHabitShort, Confidence: 0.9, CreatedAt: base,
|
||||
}
|
||||
superset := habitItem(fixtureHabitLong, base.Add(time.Hour), 0.9)
|
||||
bad := seedTidyItem(t, store, misclassified)
|
||||
seedTidyItem(t, store, superset)
|
||||
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
fixtureHabitShort: {1, 0},
|
||||
fixtureHabitLong: simVector(0.95),
|
||||
}})
|
||||
if _, err := tr.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
}
|
||||
res := candidatesByAction(cands, TidyReclassify)
|
||||
if len(res) != 1 {
|
||||
t.Fatalf("reclassify 候选 = %d, want 1(%+v)", len(res), cands)
|
||||
}
|
||||
before := loadTidyItemRow(t, store, bad.ID)
|
||||
|
||||
if out, err := store.ApplyTidyCandidate(ctx, res[0].ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
|
||||
t.Fatalf("ApplyTidyCandidate: %v", err)
|
||||
} else if out.Status != TidyApplyApplied {
|
||||
t.Fatalf("reclassify 应用 = %+v, want applied", out)
|
||||
}
|
||||
after := loadTidyItemRow(t, store, bad.ID)
|
||||
if after.kind != string(KindRelationship) {
|
||||
t.Fatalf("reclassify 后 kind = %q, want %q", after.kind, KindRelationship)
|
||||
}
|
||||
if after.content != before.content || after.status != before.status {
|
||||
t.Fatalf("reclassify 改了不该改的列:%+v(before=%+v)", after, before)
|
||||
}
|
||||
|
||||
if _, err := store.RevertTidyCandidate(ctx, res[0].ID, TidyApplyOptions{Actor: "owner:test"}); err != nil {
|
||||
t.Fatalf("RevertTidyCandidate: %v", err)
|
||||
}
|
||||
if got := loadTidyItemRow(t, store, bad.ID); got.kind != before.kind {
|
||||
t.Fatalf("回滚后 kind = %q, want %q(还原成候选证据里的旧值)", got.kind, before.kind)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyApplyLinkIsNotApplyable 钉住⑤:link 没有执行语义(design/23 §14:
|
||||
// 链接的存储形态未定),应用它只能得到 skipped,且**零改动**。
|
||||
func TestTidyApplyLinkIsNotApplyable(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureEnergyRedBull, base, 0.95))
|
||||
seedTidyItem(t, store, habitItem(fixtureEnergyIced, base.Add(time.Minute), 0.95))
|
||||
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), stubEmbedder{vecs: map[string]embedding.Vector{
|
||||
fixtureEnergyRedBull: {1, 0},
|
||||
fixtureEnergyIced: simVector(0.99),
|
||||
}})
|
||||
if _, err := tr.RunOnce(ctx); err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
cands, err := store.ListTidyCandidates(ctx, "", 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyCandidates: %v", err)
|
||||
}
|
||||
links := candidatesByAction(cands, TidyLink)
|
||||
if len(links) != 1 {
|
||||
t.Fatalf("link 候选 = %d, want 1", len(links))
|
||||
}
|
||||
before := dumpTidyTable(t, store, "memory_items", memoryItemsColumns)
|
||||
out, err := store.ApplyTidyCandidate(ctx, links[0].ID, TidyApplyOptions{Actor: "owner:test"})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTidyCandidate(link): %v", err)
|
||||
}
|
||||
if out.Status != TidyApplySkipped || out.Reason != tidySkipActionNotApplyable {
|
||||
t.Fatalf("link 应用结果 = %+v, want skipped/%s", out, tidySkipActionNotApplyable)
|
||||
}
|
||||
if len(out.AffectedIDs) != 0 {
|
||||
t.Fatalf("不可应用的动作不得声称改过行:%v", out.AffectedIDs)
|
||||
}
|
||||
if got := dumpTidyTable(t, store, "memory_items", memoryItemsColumns); len(got) != len(before) {
|
||||
t.Fatalf("link 应用改动了记忆:%d → %d 行", len(before), len(got))
|
||||
}
|
||||
if cand, err := store.GetTidyCandidate(ctx, links[0].ID); err != nil || cand.Status != tidyCandidatePending {
|
||||
t.Fatalf("不可应用的候选应保持 pending,实际 %q (err=%v)", cand.Status, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyApplyBatchByRunHonoursLimitAndReportsTruncated 钉住⑥:按 run 批量应用
|
||||
// 逐条给结果、上限生效、超限如实报 Truncated + Total,并把该轮留痕的 applied 累加。
|
||||
func TestTidyApplyBatchByRunHonoursLimitAndReportsTruncated(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
base := tidyTestTime(t)
|
||||
const oneOffs = 5
|
||||
ids := make([]string, 0, oneOffs)
|
||||
for i := 0; i < oneOffs; i++ {
|
||||
item := habitItem(fixtureOneOffEvent, base.Add(time.Duration(i)*time.Minute), 0.9)
|
||||
ids = append(ids, seedTidyItem(t, store, item).ID)
|
||||
}
|
||||
|
||||
tr := newTestTidier(t, store, tidyTestCfg(0.80), nil)
|
||||
stats, err := tr.RunOnce(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("RunOnce: %v", err)
|
||||
}
|
||||
if stats.ByAction[string(TidyArchive)] != oneOffs {
|
||||
t.Fatalf("ByAction[archive] = %d, want %d(%+v)", stats.ByAction[string(TidyArchive)], oneOffs, stats.ByAction)
|
||||
}
|
||||
|
||||
batch, err := store.ApplyTidyCandidates(ctx, stats.RunID, TidyApplyOptions{Actor: "owner:test", Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyTidyCandidates: %v", err)
|
||||
}
|
||||
if batch.Total != oneOffs || len(batch.Results) != 2 || !batch.Truncated {
|
||||
t.Fatalf("批量回执 = %+v, want total=%d results=2 truncated=true", batch, oneOffs)
|
||||
}
|
||||
if batch.Applied != 2 || batch.Archived != 2 || batch.Skipped != 0 || batch.Failed != 0 {
|
||||
t.Fatalf("批量计数 = %+v, want applied=2 archived=2", batch)
|
||||
}
|
||||
for _, res := range batch.Results {
|
||||
if res.Status != TidyApplyApplied || res.Action != TidyArchive || len(res.AffectedIDs) != 1 {
|
||||
t.Fatalf("逐条结果 = %+v, want 每条 applied/archive 带 1 个 affected id", res)
|
||||
}
|
||||
}
|
||||
archived := 0
|
||||
for _, id := range ids {
|
||||
if loadTidyItemRow(t, store, id).status == tidyStatusArchived {
|
||||
archived++
|
||||
}
|
||||
}
|
||||
if archived != 2 {
|
||||
t.Fatalf("实际归档 = %d, want 2", archived)
|
||||
}
|
||||
runs, err := store.ListTidyRuns(ctx, 10)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyRuns: %v", err)
|
||||
}
|
||||
if len(runs) != 1 || runs[0].Applied != 2 {
|
||||
t.Fatalf("运行留痕 applied = %d (runs=%d), want 2", runs[0].Applied, len(runs))
|
||||
}
|
||||
|
||||
// 剩余 3 条:第二批应用,超限标记消失(Total 已 ≤ 上限)。
|
||||
batch2, err := store.ApplyTidyCandidates(ctx, stats.RunID, TidyApplyOptions{Actor: "owner:test", Limit: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("第二批: %v", err)
|
||||
}
|
||||
if batch2.Total != oneOffs-2 || !batch2.Truncated || batch2.Applied != 2 {
|
||||
t.Fatalf("第二批回执 = %+v, want total=3 truncated=true applied=2", batch2)
|
||||
}
|
||||
|
||||
// runId 拼错必须是「查不到这一轮」,不是「这一轮没有待定候选」。
|
||||
if _, err := store.ApplyTidyCandidates(ctx, "no-such-run", TidyApplyOptions{Actor: "owner:test"}); !errors.Is(err, ErrTidyRunRecordNotFound) {
|
||||
t.Fatalf("未知 runId 的 err = %v, want ErrTidyRunRecordNotFound", err)
|
||||
}
|
||||
// runId 为空 ⇒ 「全部待定应用」这一档(没有轮次筛)。
|
||||
rest, err := store.ApplyTidyCandidates(ctx, "", TidyApplyOptions{Actor: "owner:test"})
|
||||
if err != nil {
|
||||
t.Fatalf("全部待定应用: %v", err)
|
||||
}
|
||||
if rest.Total != 1 || rest.Applied != 1 || rest.Archived != 1 {
|
||||
t.Fatalf("全部待定回执 = %+v, want total=1 applied=1 archived=1", rest)
|
||||
}
|
||||
}
|
||||
|
||||
// jsonHasID 报告一个 JSON 数组文本里是否含 id。
|
||||
func jsonHasID(t *testing.T, jsonText, id string) bool {
|
||||
t.Helper()
|
||||
var ids []string
|
||||
if err := json.Unmarshal([]byte(jsonText), &ids); err != nil {
|
||||
t.Fatalf("解析 %q: %v", jsonText, err)
|
||||
}
|
||||
return containsID(ids, id)
|
||||
}
|
||||
@@ -39,14 +39,17 @@ type TidyEvidence struct {
|
||||
|
||||
// TidyCandidate 是一条整理建议(落库形状,design/23 §10)。
|
||||
type TidyCandidate struct {
|
||||
ID string
|
||||
PersonaID string
|
||||
Scope Scope
|
||||
ProjectID string
|
||||
Kind Kind
|
||||
Action TidyAction
|
||||
SourceIDs []string
|
||||
TargetID string
|
||||
ID string
|
||||
PersonaID string
|
||||
Scope Scope
|
||||
ProjectID string
|
||||
Kind Kind
|
||||
Action TidyAction
|
||||
SourceIDs []string
|
||||
TargetID string
|
||||
// RunID 是产出这条候选的那一轮整理(切片 TIDY-1 补的列:批量应用的抓手)。
|
||||
// 幂等跳过的候选保留**首次**产它的那一轮,不被后来的轮次改写。
|
||||
RunID string
|
||||
ProposedContent string
|
||||
MergedContent string
|
||||
Reason string
|
||||
@@ -151,6 +154,11 @@ func buildCandidates(pr tidyPair, v tidyVerdict, now time.Time) []TidyCandidate
|
||||
re.MergedContent = ""
|
||||
re.Reason = "kind_mismatch"
|
||||
re.Evidence.ProposedKind = string(target.Kind)
|
||||
// KindA 在 reclassify 候选上**重新定义**为「被改那一行当前的 kind」:
|
||||
// merge 候选的 KindA/KindB 是一对各自的 kind(供 diff 预览),而
|
||||
// reclassify 候选只有一个主体(absorbed),回滚必须知道它原来的 kind,
|
||||
// 否则 revert 会拿另一条行的 kind 去覆盖(design/23 §8 的可逆口径)。
|
||||
re.Evidence.KindA = string(absorbed.Kind)
|
||||
out = append(out, re)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -70,6 +70,17 @@ const (
|
||||
// minTidyContentRunes 是参与比对的最小内容长度(按 rune)。过短的内容
|
||||
// (如单字)没有可判定的事实要素,进比对只会产出噪声,直接跳过。
|
||||
minTidyContentRunes = 3
|
||||
|
||||
// defaultTidyApplyBatchLimit 是「批量应用候选」的单次条数上限(切片 TIDY-1)。
|
||||
// 它有上限是**刻意**的:一次点下去改几百条记忆是不可回头的观感,因此宁可
|
||||
// 让主人分批点,且回执如实带上 Total/Truncated(超限绝不静默少应用)。
|
||||
// 它是**代码默认值**(`.env` 已冻结、design/13 §2.1:新旋钮一律走
|
||||
// $DATA_DIR/config/** 或代码默认值),不新增任何 env 键。
|
||||
defaultTidyApplyBatchLimit = 100
|
||||
|
||||
// maxTidyApplyBatchLimit 是批量应用上限的硬顶:请求给更大的值就钳到这里
|
||||
// (与既有查询面「钳位而不是报错」同一约定)。
|
||||
maxTidyApplyBatchLimit = 500
|
||||
)
|
||||
|
||||
// TidyConfig 携带记忆整理的调优旋钮。零值经 normalize 选取安全默认值,
|
||||
@@ -173,3 +184,21 @@ func (c TidyConfig) nextQuietStart(at time.Time) time.Time {
|
||||
}
|
||||
return start
|
||||
}
|
||||
|
||||
// currentQuietStart 返回 at **所在**的低峰窗口起点(不晚于 at):
|
||||
// 落在窗口里(含跨午夜的半夜那一段)时是今天/昨天的 QuietStartHour 整点。
|
||||
// 窗口关闭时返回零值时间。
|
||||
//
|
||||
// 它服务**补跑判据**(切片 TIDY-1):判断「这个窗口是不是已经跑过」只需要
|
||||
// 「窗口起点 ≤ 上次 run」这一个比较,不必去构造「窗口内哪天」这种概念 ——
|
||||
// 跨午夜的窗口(23:00–07:00)用日期算会算错。
|
||||
func (c TidyConfig) currentQuietStart(at time.Time) time.Time {
|
||||
if c.quietWindowDisabled() {
|
||||
return time.Time{}
|
||||
}
|
||||
start := time.Date(at.Year(), at.Month(), at.Day(), c.QuietStartHour, 0, 0, 0, at.Location())
|
||||
if start.After(at) {
|
||||
start = start.AddDate(0, 0, -1)
|
||||
}
|
||||
return start
|
||||
}
|
||||
@@ -42,7 +42,8 @@ var (
|
||||
tidyCandidateColumns = []string{
|
||||
"id", "persona_id", "scope", "project_id", "kind", "action", "source_ids", "target_id",
|
||||
"proposed_content", "merged_content", "reason", "evidence", "rule_version",
|
||||
"score_min", "score_max", "status", "idempotency_key", "created_at", "decided_at", "decided_by",
|
||||
"score_min", "score_max", "status", "idempotency_key", "run_id", "created_at",
|
||||
"decided_at", "decided_by",
|
||||
}
|
||||
tidyRunColumns = []string{
|
||||
"id", "started_at", "finished_at", "scanned", "candidates", "applied", "skipped",
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 本文件锁定记忆整理**调度修复**(切片 TIDY-1)的三条判据:
|
||||
//
|
||||
// ① **重启不饿死**:进程在安静窗内启动、且上次 run 已超过 interval ⇒ 启动即补跑
|
||||
// (旧实现只看进程内 lastRun + 24h ticker,后端两天重启十几次 ⇒ 永远不跑);
|
||||
// ② **跨过安静窗起点会跑**:启动在窗口外、无历史 ⇒ 等到窗口起点那一轮自己跑;
|
||||
// ③ **不重复跑**:同一安静窗内已经跑过(lastRun 在窗口起点之后)⇒ 重启不再跑,
|
||||
// 否则「修好饿死」会变成「每次重启都全库扫一遍」。
|
||||
//
|
||||
// 用例全部用**注入时钟 + 假等待**:不真等、不靠真实时间。假等待把假时钟推进到
|
||||
// 下一次唤醒时刻,因此「跨过窗口起点」是可确定性验证的。
|
||||
|
||||
// schedClock 是可推进的假时钟。
|
||||
type schedClock struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (c *schedClock) now() time.Time {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.at
|
||||
}
|
||||
|
||||
func (c *schedClock) advance(d time.Duration) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.at = c.at.Add(d)
|
||||
}
|
||||
|
||||
// tidyWaitProbe 是假等待 + 「已被调用」信号的组合:假等待把假时钟推进到下一次
|
||||
// 唤醒时刻,同时把每次调用记进 signals —— 于是用例可以**确定性地**等到
|
||||
// 「循环已经走到某个阶段」再断言 / 再 Stop,不必真等也不必 sleep。
|
||||
//
|
||||
// 信号序列与循环的对应关系:第 1 次 wait 之前一定已经做完启动补跑判定;
|
||||
// 第 N+1 次 wait 之前一定已经做完第 N 次唤醒后的那一轮。
|
||||
type tidyWaitProbe struct {
|
||||
clock *schedClock
|
||||
budget int
|
||||
calls int
|
||||
signals chan int
|
||||
}
|
||||
|
||||
func newTidyWaitProbe(clock *schedClock, budget int) *tidyWaitProbe {
|
||||
return &tidyWaitProbe{clock: clock, budget: budget, signals: make(chan int, 16)}
|
||||
}
|
||||
|
||||
func (p *tidyWaitProbe) wait(ctx context.Context, d time.Duration) bool {
|
||||
p.calls++
|
||||
select {
|
||||
case p.signals <- p.calls:
|
||||
default:
|
||||
}
|
||||
if p.calls > p.budget {
|
||||
return false
|
||||
}
|
||||
p.clock.advance(d)
|
||||
return true
|
||||
}
|
||||
|
||||
// awaitCalls 等到假等待已被调用 n 次(每次至多等 5 秒;超时即 FAIL)。
|
||||
func (p *tidyWaitProbe) awaitCalls(t *testing.T, n int) {
|
||||
t.Helper()
|
||||
for i := 0; i < n; i++ {
|
||||
select {
|
||||
case <-p.signals:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatalf("等待第 %d 次心跳唤醒超时(循环没走到预期阶段)", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// seedTidyRun 直接落一条运行留痕(模拟「上次 run 的持久事实」)。
|
||||
func seedTidyRun(t *testing.T, store *Store, startedAt time.Time) {
|
||||
t.Helper()
|
||||
if err := store.persistTidyRun(context.Background(), TidyRun{
|
||||
ID: mustNewTidyID(),
|
||||
StartedAt: startedAt.UTC(),
|
||||
FinishedAt: startedAt.UTC(),
|
||||
RuleVersion: TidyRuleVersion,
|
||||
}); err != nil {
|
||||
t.Fatalf("写运行留痕: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// tidyRunTimes 返回运行留痕的开始时刻(最新在前)。
|
||||
func tidyRunTimes(t *testing.T, store *Store) []string {
|
||||
t.Helper()
|
||||
runs, err := store.ListTidyRuns(context.Background(), 50)
|
||||
if err != nil {
|
||||
t.Fatalf("ListTidyRuns: %v", err)
|
||||
}
|
||||
out := make([]string, 0, len(runs))
|
||||
for _, r := range runs {
|
||||
out = append(out, r.StartedAt.UTC().Format(time.RFC3339))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestTidyServiceCatchUpAfterRestartDoesNotStarve 是判据①:上次 run 已超过
|
||||
// interval(这里用 25h),进程在安静窗内重启 ⇒ **启动即补跑**,不等 ticker。
|
||||
//
|
||||
// 假等待立刻返回 false(模拟「还没到下一个窗口,进程又被重启了」)—— 这正是
|
||||
// 后端两天重启十几次的真实情形:只看 ticker 的实现会永远不跑。
|
||||
func TestTidyServiceCatchUpAfterRestartDoesNotStarve(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
|
||||
|
||||
// 上次 run:两天前 23:00(安静窗内),现在 23:30 ⇒ 距上次 48.5h ≥ 24h。
|
||||
last := time.Date(2026, 10, 5, 23, 0, 0, 0, time.Local)
|
||||
seedTidyRun(t, store, last)
|
||||
now := time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local)
|
||||
|
||||
clock := &schedClock{at: now}
|
||||
probe := newTidyWaitProbe(clock, 0) // 一次都不等到下一次窗口
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
|
||||
svc.wait = probe.wait
|
||||
svc.Start(ctx)
|
||||
probe.awaitCalls(t, 1) // 循环已走完启动补跑判定并进入等待
|
||||
if err := svc.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
|
||||
times := tidyRunTimes(t, store)
|
||||
if len(times) != 2 {
|
||||
t.Fatalf("运行留痕 = %v, want 2(历史 1 + 启动补跑 1)", times)
|
||||
}
|
||||
if times[0] != now.UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("补跑时刻 = %q, want 启动时刻 %q", times[0], now.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceCatchUpOnFirstStartInsideQuietHours 是判据①的「无历史」分支:
|
||||
// 全新库在安静窗内启动 ⇒ 立刻跑一轮(旧口径会一直等到下一个 23:00)。
|
||||
func TestTidyServiceCatchUpOnFirstStartInsideQuietHours(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
|
||||
|
||||
now := time.Date(2026, 10, 7, 23, 5, 0, 0, time.Local)
|
||||
clock := &schedClock{at: now}
|
||||
probe := newTidyWaitProbe(clock, 0)
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
|
||||
svc.wait = probe.wait
|
||||
svc.Start(ctx)
|
||||
probe.awaitCalls(t, 1)
|
||||
if err := svc.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
times := tidyRunTimes(t, store)
|
||||
if len(times) != 1 || times[0] != now.UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("运行留痕 = %v, want 恰为启动时刻 %q", times, now.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceRunsWhenQuietWindowStarts 是判据②:启动在窗口外(22:00)且无
|
||||
// 历史 ⇒ 先不跑;假等待把时钟推进到窗口起点(23:00)⇒ 心跳跑一轮。
|
||||
func TestTidyServiceRunsWhenQuietWindowStarts(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
|
||||
|
||||
clock := &schedClock{at: time.Date(2026, 10, 7, 22, 0, 0, 0, time.Local)}
|
||||
probe := newTidyWaitProbe(clock, 1) // 第一次唤醒(= 窗口起点)后放行,第二次退出
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
|
||||
svc.wait = probe.wait
|
||||
svc.Start(ctx)
|
||||
probe.awaitCalls(t, 2) // 第 2 次唤醒之前必然已跑完窗口起点那一轮
|
||||
if err := svc.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
want := time.Date(2026, 10, 7, 23, 0, 0, 0, time.Local)
|
||||
times := tidyRunTimes(t, store)
|
||||
if len(times) != 1 || times[0] != want.UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("运行留痕 = %v, want 恰为窗口起点 %q", times, want.UTC().Format(time.RFC3339))
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceDoesNotRerunInsideSameQuietWindow 是判据③(防过度补跑):
|
||||
// 同一安静窗内已经跑过 ⇒ 重启不再跑。窗口是跨午夜的(23:00–07:00),因此
|
||||
// 用例特意用「今天 00:30」这种「窗口起点在昨天 23:00」的时刻。
|
||||
func TestTidyServiceDoesNotRerunInsideSameQuietWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
|
||||
|
||||
last := time.Date(2026, 10, 6, 23, 5, 0, 0, time.Local) // 窗口起点 23:00 之后
|
||||
seedTidyRun(t, store, last)
|
||||
clock := &schedClock{at: time.Date(2026, 10, 7, 0, 30, 0, 0, time.Local)} // 同一窗口内
|
||||
probe := newTidyWaitProbe(clock, 0)
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
|
||||
svc.wait = probe.wait
|
||||
svc.Start(ctx)
|
||||
probe.awaitCalls(t, 1)
|
||||
if err := svc.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
if times := tidyRunTimes(t, store); len(times) != 1 {
|
||||
t.Fatalf("运行留痕 = %v, want 只保留历史那一条(同一窗口不重复跑)", times)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceDueNowJudgements 把 dueNow 的三条判据逐条钉死(纯函数式断言,
|
||||
// 不涉及 goroutine)。
|
||||
func TestTidyServiceDueNowJudgements(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newTestStore(t)
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, nil, nil)
|
||||
|
||||
// 时钟由用例直接改写 svc.now(不经过 Start)。
|
||||
at := func(tm time.Time) { svc.now = func() time.Time { return tm } }
|
||||
setLast := func(tm time.Time) { svc.lastRun = tm }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
now time.Time
|
||||
last time.Time
|
||||
due bool
|
||||
}{
|
||||
{
|
||||
name: "无历史且在窗口外 ⇒ 不补跑",
|
||||
now: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local),
|
||||
last: time.Time{}, due: false,
|
||||
},
|
||||
{
|
||||
name: "无历史且在窗口内 ⇒ 补跑",
|
||||
now: time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local),
|
||||
last: time.Time{}, due: true,
|
||||
},
|
||||
{
|
||||
name: "距上次已超 interval ⇒ 补跑(窗口外也跑)",
|
||||
now: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local),
|
||||
last: time.Date(2026, 10, 6, 14, 0, 0, 0, time.Local), due: true,
|
||||
},
|
||||
{
|
||||
name: "窗口内且本窗口未跑 ⇒ 补跑",
|
||||
now: time.Date(2026, 10, 7, 23, 30, 0, 0, time.Local),
|
||||
last: time.Date(2026, 10, 7, 12, 0, 0, 0, time.Local), due: true,
|
||||
},
|
||||
{
|
||||
name: "窗口内且本窗口已跑 ⇒ 不补跑(跨午夜也算同一窗口)",
|
||||
now: time.Date(2026, 10, 7, 0, 30, 0, 0, time.Local),
|
||||
last: time.Date(2026, 10, 6, 23, 5, 0, 0, time.Local), due: false,
|
||||
},
|
||||
{
|
||||
name: "窗口外且未到周期 ⇒ 不补跑",
|
||||
now: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local),
|
||||
last: time.Date(2026, 10, 7, 2, 0, 0, 0, time.Local), due: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
at(tc.now)
|
||||
setLast(tc.last)
|
||||
due, reason := svc.dueNow()
|
||||
if due != tc.due {
|
||||
t.Fatalf("dueNow = %v(%s), want %v", due, reason, tc.due)
|
||||
}
|
||||
if due && reason == "" {
|
||||
t.Fatal("补跑必须带原因标签(日志/排障要用)")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceManualRunRespectsDisabled 钉住手动真跑入口的口径:关闭时如实
|
||||
// 返回 ErrTidyDisabled(不退化成空结果),开启时与 RunOnce 同一条路径。
|
||||
func TestTidyServiceManualRunRespectsDisabled(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
|
||||
off := tidyTestCfg(0.80)
|
||||
off.Enabled = false
|
||||
svcOff := newTidyServiceForTest(t, store, off, nil, nil, nil)
|
||||
if _, err := svcOff.ManualRun(ctx); err != ErrTidyDisabled {
|
||||
t.Fatalf("关闭时 ManualRun err = %v, want ErrTidyDisabled", err)
|
||||
}
|
||||
|
||||
on := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, nil, nil)
|
||||
stats, err := on.ManualRun(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ManualRun: %v", err)
|
||||
}
|
||||
if stats.RunID == "" {
|
||||
t.Fatal("真跑必须留下运行 id(候选按它归属)")
|
||||
}
|
||||
runs := tidyRunTimes(t, store)
|
||||
if len(runs) != 1 {
|
||||
t.Fatalf("运行留痕 = %v, want 1", runs)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,11 @@ import (
|
||||
|
||||
// createMemoryTidyCandidatesTable 是人工确认队列(design/23 §10)。
|
||||
// idempotency_key 唯一:同一批数据重复跑不重复产候选(design/23 §9)。
|
||||
//
|
||||
// run_id 是**本实现在设计稿列集之上补的一列**(切片 TIDY-1):设计稿 §10 的
|
||||
// 候选表没有「哪一轮产的」这一列,于是「按 run 批量应用」只能靠时间窗猜 ——
|
||||
// 那是把幂等键的语义换成时间戳比较。run_id 让批量应用有确定的抓手,且
|
||||
// **不进幂等键**(否则同一批数据每轮都会重复产候选),因此老语义逐字不变。
|
||||
const createMemoryTidyCandidatesTable = `
|
||||
CREATE TABLE IF NOT EXISTS memory_tidy_candidates (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -36,6 +41,7 @@ CREATE TABLE IF NOT EXISTS memory_tidy_candidates (
|
||||
score_max REAL NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
run_id TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
decided_at TEXT NOT NULL DEFAULT '',
|
||||
decided_by TEXT NOT NULL DEFAULT ''
|
||||
@@ -45,6 +51,10 @@ const createMemoryTidyCandidatesStatusIndex = `
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_tidy_candidates_status
|
||||
ON memory_tidy_candidates (status, scope)`
|
||||
|
||||
const createMemoryTidyCandidatesRunIndex = `
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_tidy_candidates_run
|
||||
ON memory_tidy_candidates (run_id, status)`
|
||||
|
||||
// createMemoryTidyRunsTable 是整理运行的留痕(design/23 §4.1 F:留痕不靠
|
||||
// 会话)。vetoed / veto_reasons 是本实现在设计稿列集之上补的两列:design/23
|
||||
// §9 要求「每轮记否决原因分布」,只落日志就无法查询,故一并入库。
|
||||
@@ -86,14 +96,30 @@ const createMemoryItemsStatusIndex = `
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_items_status
|
||||
ON memory_items (persona_id, status)`
|
||||
|
||||
// ensureTidySchema 幂等地创建整理所需的两张新表、状态索引,并前滚
|
||||
// memory_items 的整理列。它由 NewStore 调用,因此整理能力在任何
|
||||
// *sql.DB 上都可用(与 memory_items 自身同一口径)。
|
||||
// ensureTidySchema 幂等地建整理所需的两张新表、前滚列、再建索引。它由 NewStore
|
||||
// 调用,因此整理能力在任何 *sql.DB 上都可用(与 memory_items 自身同一口径)。
|
||||
//
|
||||
// 顺序**必须**是「建表 → 前滚列 → 建索引」:既有库上的候选表没有 run_id,
|
||||
// 若先建 run_id 上的索引,SQLite 会报 "no such column: run_id"(旧实现里
|
||||
// 索引只依赖建表时就有的列,因此没暴露这个顺序问题)。
|
||||
func ensureTidySchema(ctx context.Context, db *sql.DB) error {
|
||||
for _, ddl := range []string{
|
||||
createMemoryTidyCandidatesTable,
|
||||
createMemoryTidyCandidatesStatusIndex,
|
||||
createMemoryTidyRunsTable,
|
||||
} {
|
||||
if _, err := db.ExecContext(ctx, ddl); err != nil {
|
||||
return fmt.Errorf("memory: tidy schema: %w", err)
|
||||
}
|
||||
}
|
||||
if err := ensureTidyMemoryColumns(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ensureTidyCandidateRunColumn(ctx, db); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ddl := range []string{
|
||||
createMemoryTidyCandidatesStatusIndex,
|
||||
createMemoryTidyCandidatesRunIndex,
|
||||
createMemoryTidyRunsStartedIndex,
|
||||
createMemoryItemsStatusIndex,
|
||||
} {
|
||||
@@ -101,7 +127,27 @@ func ensureTidySchema(ctx context.Context, db *sql.DB) error {
|
||||
return fmt.Errorf("memory: tidy schema: %w", err)
|
||||
}
|
||||
}
|
||||
return ensureTidyMemoryColumns(ctx, db)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTidyCandidateRunColumn 把 run_id 前滚到**既有**的候选表上(切片 TIDY-1):
|
||||
// CREATE TABLE IF NOT EXISTS 只在建表时生效,既有库的表已经建好,因此新列必须
|
||||
// 单独补一次。手法与 ensureTidyMemoryColumns 一致:已存在时 SQLite 报
|
||||
// "duplicate column name: run_id",那是预期路径。
|
||||
//
|
||||
// ⚠️ 与迁移的关系:internal/db 的 0019_memory_tidy 是权威迁移,本文件是
|
||||
// 「记忆存储自包含、可在任意 *sql.DB 上使用」的前滚(两处口径必须一致)。
|
||||
// 本列目前**只在内存包侧前滚**,未同步进 0019(改迁移文件超出本切片边界),
|
||||
// 组合根每次启动都会跑到这里,因此生产库在下次重启时即可拿到该列。
|
||||
func ensureTidyCandidateRunColumn(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx,
|
||||
`ALTER TABLE memory_tidy_candidates ADD COLUMN run_id TEXT NOT NULL DEFAULT ''`); err != nil {
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("memory: roll forward tidy candidate run_id: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureTidyMemoryColumns 把 memory_items 的整理列前滚到既有表上:列缺失时
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -20,17 +22,25 @@ import (
|
||||
// - 它不是任何角色:系统任务,不带人格提示词,也不把写入限到某个角色的域;
|
||||
// - 不写会话:唯一的写入是候选表与运行表;agent 侧对 chat_messages 零写入。
|
||||
//
|
||||
// 心跳范式参照 internal/services/backthink(同一低峰/安静时段语义),但有一处
|
||||
// **有意的不同**:它**不做启动即跑**。backthink 的启动轮是产品行为(一句
|
||||
// 拟人化问候),而整理启动即跑会在每次重启时触发一次全库向量化扫描 —— 那是
|
||||
// 主人没要求的开销。整理因此只在安静时段起点开跑。
|
||||
// 心跳范式参照 internal/services/backthink(同一低峰/安静时段语义),但在
|
||||
// 「启动即跑」这一点上与本包旧版**有意不同**(切片 TIDY-1 修掉的正是旧口径):
|
||||
//
|
||||
// - 旧口径:进程启动时只算「下一个安静时段起点」,且「上次 run」只存在进程
|
||||
// 内存里。后端两天重启十几次 ⇒ 24h 周期永远走不完、安静窗起点永远被下一次
|
||||
// 重启覆盖,于是 memory_tidy_runs 只有 1 行、77 条候选全 pending。
|
||||
// - 新口径(到点即跑 + 持久事实):启动时先读**持久事实**
|
||||
// (memory_tidy_runs 里最近一轮的开始时刻),满足任一条件就**立刻补跑一次**:
|
||||
// ① 距上次 run 已 ≥ interval;② 已在安静窗内且窗口起点晚于上次 run
|
||||
// (= 这个窗口还没跑过)。此后心跳照旧只在安静窗内跑。
|
||||
//
|
||||
// 这样「重启不饿死」是可证的:重启只影响内存,判据只依赖持久事实与墙上时钟。
|
||||
|
||||
// minTidyTickDelay 是心跳循环的最小等待,防止配置异常(周期/时钟跳变)导致
|
||||
// 空转忙轮询。
|
||||
const minTidyTickDelay = time.Minute
|
||||
|
||||
// TidyService 是记忆整理服务。用 NewTidyService 构建,用 Start 启动心跳、
|
||||
// Stop 停止它;RunOnce 是「立即整理」入口(design/23 §4 的手动可触发)。
|
||||
// Stop 停止它;RunOnce / ManualRun 是「立即整理」入口(design/23 §4 的手动可触发)。
|
||||
type TidyService struct {
|
||||
tidier *Tidier
|
||||
cfg TidyConfig
|
||||
@@ -42,6 +52,13 @@ type TidyService struct {
|
||||
lastRun time.Time
|
||||
wg sync.WaitGroup
|
||||
|
||||
// now 是调度用的时钟(默认取整理器的注入时钟)。测试注入它以模拟
|
||||
// 「启动 → 跨过安静窗起点」而不用真等一整夜。
|
||||
now func() time.Time
|
||||
// wait 是心跳的等待原语:返回 false 表示 ctx 已取消。生产是 time.Timer;
|
||||
// 测试注入假等待(推进假时钟后立刻返回),因此补跑判据可以确定性验证。
|
||||
wait func(ctx context.Context, d time.Duration) bool
|
||||
|
||||
// runMu 保证同一时刻只有一轮整理(design/23 §9 单写者):心跳与手动触发
|
||||
// 若撞在一起,后者排队而不是并发改写候选表。
|
||||
runMu sync.Mutex
|
||||
@@ -61,10 +78,24 @@ func NewTidyService(cfg TidyConfig, deps TidyDeps) (*TidyService, error) {
|
||||
tidier: tidier,
|
||||
cfg: tidier.Config(),
|
||||
log: tidier.log,
|
||||
now: tidier.now,
|
||||
wait: waitTidyTimer,
|
||||
runs: newTidyRunRegistry(defaultTidyRunTTL),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// waitTidyTimer 是生产等待:真定时器 + ctx 取消感知。
|
||||
func waitTidyTimer(ctx context.Context, d time.Duration) bool {
|
||||
timer := time.NewTimer(d)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// SessionID 返回整理运行的专属隔离会话标识(design/23 §4.1 B)。
|
||||
func (s *TidyService) SessionID() string { return TidySessionID }
|
||||
|
||||
@@ -93,6 +124,9 @@ func (s *TidyService) Start(ctx context.Context) {
|
||||
s.wg.Add(1)
|
||||
s.mu.Unlock()
|
||||
|
||||
// 持久事实先读进来:判定「要不要补跑」只看它,不看进程内状态。
|
||||
s.loadPersistedLastRun(ctx)
|
||||
|
||||
go s.loop(cctx)
|
||||
|
||||
s.log.Info("memory tidy started",
|
||||
@@ -103,7 +137,41 @@ func (s *TidyService) Start(ctx context.Context) {
|
||||
"compare_limit", s.cfg.CompareLimit,
|
||||
"candidate_limit", s.cfg.CandidateLimit,
|
||||
"rule_version", TidyRuleVersion,
|
||||
"session_id", TidySessionID)
|
||||
"session_id", TidySessionID,
|
||||
"last_run_at", s.lastRunLabel())
|
||||
}
|
||||
|
||||
// loadPersistedLastRun 把「上次 run 的时刻」从 memory_tidy_runs 读进内存
|
||||
// (切片 TIDY-1 的持久事实)。读不到时只记 WARN 并保持零值:那是「没有历史」
|
||||
// 这一正常状态,调度会退到「安静窗内且未跑过 ⇒ 补跑」那一档。
|
||||
//
|
||||
// 零值会**保守地**让补跑更容易发生(窗口外无历史时不跑、窗口内直接跑一次),
|
||||
// 这正是「宁可多跑一轮,不可永远不跑」的取向 —— 与旧口径相反,因为旧口径的
|
||||
// 「省一次扫描」代价是两天一条没应用。
|
||||
func (s *TidyService) loadPersistedLastRun(ctx context.Context) {
|
||||
last, err := s.tidier.deps.Store.LastTidyRunStartedAt(ctx)
|
||||
if err != nil {
|
||||
s.log.Warn("memory tidy last run unreadable, catch-up will rely on the quiet window",
|
||||
"error", err)
|
||||
return
|
||||
}
|
||||
if last.IsZero() {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.lastRun = last
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// lastRunLabel 渲染上次 run 的日志标签(零值 = 从未跑过)。
|
||||
func (s *TidyService) lastRunLabel() string {
|
||||
s.mu.Lock()
|
||||
last := s.lastRun
|
||||
s.mu.Unlock()
|
||||
if last.IsZero() {
|
||||
return "never"
|
||||
}
|
||||
return last.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
// Stop 停止心跳并等待进行中的一轮跑完。它是幂等的;若 ctx 在排空完成前被
|
||||
@@ -143,26 +211,38 @@ func (s *TidyService) RunOnce(ctx context.Context) (TidyStats, error) {
|
||||
return s.runRound(ctx, "manual")
|
||||
}
|
||||
|
||||
// loop 是心跳 goroutine:算出下一次低峰时刻 → 等到 →(在低峰窗口内时)跑一轮
|
||||
// → 重算。ctx 被取消时退出。
|
||||
// ManualRun 是**手动真跑**入口(切片 TIDY-1;契约的
|
||||
// POST /admin/memories/tidy/run)。它与心跳跑的是同一条规则路径、同一份配置,
|
||||
// 差别只有两处:
|
||||
//
|
||||
// 1. **尊重配置开关**:关闭时如实返回 ErrTidyDisabled,不退化成空结果;
|
||||
// 2. **触发标签是 manual**:审计里能区分「人点的」与「心跳/补跑的」。
|
||||
//
|
||||
// 它写候选与运行留痕(与试算 DryRun 的零副作用相反),但**绝不自动应用** ——
|
||||
// 应用是另一条显式入口(候选 apply / 按 run 批量 apply),保持 §7 的人工在环。
|
||||
func (s *TidyService) ManualRun(ctx context.Context) (TidyStats, error) {
|
||||
if !s.cfg.Enabled {
|
||||
return TidyStats{}, ErrTidyDisabled
|
||||
}
|
||||
return s.runRound(ctx, "manual")
|
||||
}
|
||||
|
||||
// loop 是心跳 goroutine:先做**启动补跑判定**(持久事实 + 墙上时钟),再进入
|
||||
// 「等到下一个低峰起点 → 在窗口内时跑一轮 → 重算」的循环。ctx 被取消时退出。
|
||||
func (s *TidyService) loop(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
s.catchUp(ctx)
|
||||
for {
|
||||
delay := s.nextDelay()
|
||||
timer := time.NewTimer(delay)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
if !s.wait(ctx, s.nextDelay()) {
|
||||
return
|
||||
case <-timer.C:
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if !s.cfg.inQuietHours(s.tidier.now()) {
|
||||
if !s.cfg.inQuietHours(s.now()) {
|
||||
// 低峰窗口外不跑:整理不是随时可做的动作(design/23 §4/§9)。
|
||||
s.log.Debug("memory tidy skipped: outside quiet hours",
|
||||
"now_hour", s.tidier.now().Hour())
|
||||
"now_hour", s.now().Hour())
|
||||
continue
|
||||
}
|
||||
if _, err := s.runRound(ctx, "heartbeat"); err != nil {
|
||||
@@ -171,13 +251,60 @@ func (s *TidyService) loop(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// catchUp 做启动补跑(切片 TIDY-1 的调度修复):到点即跑,绝不让「进程重启」
|
||||
// 把 24h 周期或安静窗永远推掉。
|
||||
func (s *TidyService) catchUp(ctx context.Context) {
|
||||
due, reason := s.dueNow()
|
||||
if !due {
|
||||
return
|
||||
}
|
||||
s.log.Info("memory tidy catch-up run starting",
|
||||
"reason", reason, "session_id", TidySessionID, "last_run_at", s.lastRunLabel())
|
||||
if _, err := s.runRound(ctx, "catchup"); err != nil {
|
||||
s.log.Warn("memory tidy catch-up round failed", "reason", reason, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dueNow 是**到点即跑**的判据,只依赖两样东西:持久事实(s.lastRun,由
|
||||
// memory_tidy_runs 载入)与墙上时钟。满足任一条即应立刻跑:
|
||||
//
|
||||
// 1. 从未跑过 **且** 已在安静窗内 —— 「这个窗口还没跑过」的最简情形(全新库);
|
||||
// 2. 距上次 run 已 ≥ interval —— 周期到点,窗口不再是必要条件(补跑);
|
||||
// 3. 已在安静窗内 **且** 窗口起点晚于上次 run —— 这个窗口还没跑过。
|
||||
//
|
||||
// 三条都**不**看「进程活了多久」,因此重启多少次都不会饿死整理。反过来,窗口
|
||||
// 内跑过一次之后再重启,条件 2/3 都不成立(lastRun 在窗口起点之后),因此也
|
||||
// 不会每次重启都重跑一轮扫描。
|
||||
func (s *TidyService) dueNow() (bool, string) {
|
||||
now := s.now()
|
||||
s.mu.Lock()
|
||||
last := s.lastRun
|
||||
s.mu.Unlock()
|
||||
|
||||
if last.IsZero() {
|
||||
if s.cfg.inQuietHours(now) {
|
||||
return true, "no_history_in_quiet_window"
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
if now.Sub(last) >= s.cfg.Interval {
|
||||
return true, "interval_elapsed"
|
||||
}
|
||||
if s.cfg.inQuietHours(now) {
|
||||
if start := s.cfg.currentQuietStart(now); last.Before(start) {
|
||||
return true, "quiet_window_not_run"
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// nextDelay 返回距下一次整理开跑的等待时长。
|
||||
//
|
||||
// 口径:下一次 = 下一个低峰窗口起点(窗口关闭时 = 现在 + 周期),并且不早于
|
||||
// 「上一次运行 + 周期」—— 让配置的周期成为真实下限,而不是让安静时段每天
|
||||
// 一次把「每周一次」冲掉。等待恒 ≥ minTidyTickDelay,避免配置异常时空转。
|
||||
func (s *TidyService) nextDelay() time.Duration {
|
||||
now := s.tidier.now()
|
||||
now := s.now()
|
||||
next := s.cfg.nextQuietStart(now)
|
||||
if s.cfg.quietWindowDisabled() {
|
||||
next = now.Add(s.cfg.Interval)
|
||||
@@ -205,7 +332,7 @@ func (s *TidyService) runRound(ctx context.Context, trigger string) (TidyStats,
|
||||
s.log.Info("memory tidy round starting", "trigger", trigger, "session_id", TidySessionID)
|
||||
stats, err := s.tidier.RunOnce(ctx)
|
||||
s.mu.Lock()
|
||||
s.lastRun = s.tidier.now()
|
||||
s.lastRun = s.now()
|
||||
s.mu.Unlock()
|
||||
s.audit(stats, trigger)
|
||||
if err != nil {
|
||||
@@ -219,8 +346,10 @@ func (s *TidyService) runRound(ctx context.Context, trigger string) (TidyStats,
|
||||
// **不带任何记忆正文**。
|
||||
func (s *TidyService) audit(stats TidyStats, trigger string) {
|
||||
hook := s.tidier.deps.Audit
|
||||
result := fmt.Sprintf("trigger=%s scanned=%d candidates=%d vetoed=%d rejected_reasons=%s",
|
||||
trigger, stats.Scanned, stats.Candidates, stats.Vetoed, tidyReasonsLabel(stats.VetoReasons))
|
||||
result := fmt.Sprintf(
|
||||
"trigger=%s run_id=%s scanned=%d candidates=%d duplicates=%d vetoed=%d duration_ms=%d by_action=%s rejected_reasons=%s",
|
||||
trigger, stats.RunID, stats.Scanned, stats.Candidates, stats.Duplicates, stats.Vetoed,
|
||||
stats.DurationMS, tidyActionLabel(stats.ByAction), tidyReasonsLabel(stats.VetoReasons))
|
||||
if hook == nil {
|
||||
s.log.Info("memory tidy audit (no sink configured)", "what", "memory.tidy.run", "result", result)
|
||||
return
|
||||
@@ -228,6 +357,24 @@ func (s *TidyService) audit(stats TidyStats, trigger string) {
|
||||
hook(tidyAuditWho, "memory.tidy.run", result)
|
||||
}
|
||||
|
||||
// tidyActionLabel 把「新建候选按动作的分布」渲染成稳定字符串(按动作名字典序)。
|
||||
// 与 tidyReasonsLabel 同一手法,供审计与日志使用。
|
||||
func tidyActionLabel(byAction map[string]int) string {
|
||||
if len(byAction) == 0 {
|
||||
return "none"
|
||||
}
|
||||
keys := make([]string, 0, len(byAction))
|
||||
for k := range byAction {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%d", k, byAction[k]))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// tidyAuditWho 是整理审计的调用方标签:它是**系统任务**,不是任何角色
|
||||
// (design/23 §4.1 C)。
|
||||
const tidyAuditWho = "system/memorytidy"
|
||||
@@ -83,16 +83,23 @@ func TestTidyServiceNextDelay(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestTidyServiceStartDoesNotRunImmediately 锁定与 backthink 的**有意差异**:
|
||||
// 整理不做启动即跑,进程重启不会触发一次全库扫描。
|
||||
func TestTidyServiceStartDoesNotRunImmediately(t *testing.T) {
|
||||
// TestTidyServiceStartOutsideQuietWindowDoesNotRunImmediately 锁定补跑判据的
|
||||
// 一条边界(切片 TIDY-1):**无历史**且启动在安静窗**之外**时不做启动即跑 ——
|
||||
// 新装的库不该在任意时刻烧一次全库向量化扫描,等窗口起点那一轮即可。
|
||||
// 时钟是注入的固定值,因此这条断言与用例真的跑在几点无关(旧实现用
|
||||
// time.Now,在 23:00–07:00 之间跑就会抖动)。
|
||||
func TestTidyServiceStartOutsideQuietWindowDoesNotRunImmediately(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
store := newTestStore(t)
|
||||
seedTidyItem(t, store, habitItem(fixtureHabitShort, tidyTestTime(t), 0.9))
|
||||
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, time.Now, nil)
|
||||
clock := &schedClock{at: time.Date(2026, 10, 7, 15, 0, 0, 0, time.Local)}
|
||||
probe := newTidyWaitProbe(clock, 0) // 绝不真的等到窗口起点
|
||||
svc := newTidyServiceForTest(t, store, tidyTestCfg(0.80), nil, clock.now, nil)
|
||||
svc.wait = probe.wait
|
||||
svc.Start(ctx)
|
||||
probe.awaitCalls(t, 1)
|
||||
if err := svc.Stop(ctx); err != nil {
|
||||
t.Fatalf("Stop: %v", err)
|
||||
}
|
||||
@@ -101,7 +108,7 @@ func TestTidyServiceStartDoesNotRunImmediately(t *testing.T) {
|
||||
t.Fatalf("ListTidyRuns: %v", err)
|
||||
}
|
||||
if len(runs) != 0 {
|
||||
t.Fatalf("启动即跑了整理:%+v", runs)
|
||||
t.Fatalf("窗口外无历史时不该启动即跑:%+v", runs)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package memory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -55,21 +57,47 @@ const countTidyPartitionItemsSQL = `SELECT COUNT(*) FROM memory_items
|
||||
const insertTidyCandidateSQL = `INSERT OR IGNORE INTO memory_tidy_candidates
|
||||
(id, persona_id, scope, project_id, kind, action, source_ids, target_id,
|
||||
proposed_content, merged_content, reason, evidence, rule_version,
|
||||
score_min, score_max, status, idempotency_key, created_at, decided_at, decided_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '')`
|
||||
score_min, score_max, status, idempotency_key, run_id, created_at, decided_at, decided_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '')`
|
||||
|
||||
const insertTidyRunSQL = `INSERT INTO memory_tidy_runs
|
||||
(id, started_at, finished_at, scanned, candidates, applied, skipped, errors,
|
||||
vetoed, veto_reasons, rule_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
const listTidyCandidatesSQL = `SELECT id, persona_id, scope, project_id, kind, action,
|
||||
// listTidyCandidatesByRunSQL 按 **run_id + status** 取候选(最新在前)。run_id
|
||||
// 传空串表示「不按轮次筛」—— 那正是「全部待定应用」这条粒度(切片 TIDY-1
|
||||
// 三种粒度里最粗的一档)。
|
||||
const listTidyCandidatesByRunSQL = `SELECT id, persona_id, scope, project_id, kind, action,
|
||||
source_ids, target_id, proposed_content, merged_content, reason, evidence,
|
||||
rule_version, score_min, score_max, status, idempotency_key, created_at
|
||||
rule_version, score_min, score_max, status, idempotency_key, run_id, created_at
|
||||
FROM memory_tidy_candidates
|
||||
WHERE status = ?
|
||||
WHERE status = ? AND (? = '' OR run_id = ?)
|
||||
ORDER BY created_at DESC, rowid DESC LIMIT ?`
|
||||
|
||||
const countTidyCandidatesByRunSQL = `SELECT COUNT(*) FROM memory_tidy_candidates
|
||||
WHERE status = ? AND (? = '' OR run_id = ?)`
|
||||
|
||||
const selectTidyCandidateSQL = `SELECT id, persona_id, scope, project_id, kind, action,
|
||||
source_ids, target_id, proposed_content, merged_content, reason, evidence,
|
||||
rule_version, score_min, score_max, status, idempotency_key, run_id, created_at
|
||||
FROM memory_tidy_candidates WHERE id = ?`
|
||||
|
||||
const selectTidyRunSQL = `SELECT id, started_at, finished_at, scanned, candidates,
|
||||
applied, skipped, errors, vetoed, veto_reasons, rule_version
|
||||
FROM memory_tidy_runs WHERE id = ?`
|
||||
|
||||
// selectLastTidyRunSQL 取最近一轮整理的开始时刻(切片 TIDY-1 的调度判据):
|
||||
// 「上次 run 的时刻」是**持久事实**,不是进程内记忆 —— 后端两天重启十几次
|
||||
// 正是旧实现(只看内存 lastRun)永远走不完 24h 周期的根因。
|
||||
const selectLastTidyRunSQL = `SELECT started_at FROM memory_tidy_runs
|
||||
ORDER BY started_at DESC, rowid DESC LIMIT 1`
|
||||
|
||||
// incrementTidyRunAppliedSQL 在批量应用之后把该轮的 applied 计数累加上去
|
||||
// (design/23 §10 的 memory_tidy_runs.applied)。运行时那一轮写的是 0(当轮
|
||||
// 绝不自动应用,§7),这里补的是「事后来应用了几条」。
|
||||
const incrementTidyRunAppliedSQL = `UPDATE memory_tidy_runs SET applied = applied + ? WHERE id = ?`
|
||||
|
||||
const listTidyRunsSQL = `SELECT id, started_at, finished_at, scanned, candidates,
|
||||
applied, skipped, errors, vetoed, veto_reasons, rule_version
|
||||
FROM memory_tidy_runs ORDER BY started_at DESC, rowid DESC LIMIT ?`
|
||||
@@ -141,7 +169,7 @@ func (s *Store) insertTidyCandidate(ctx context.Context, c TidyCandidate) (bool,
|
||||
c.ID, c.PersonaID, string(c.Scope), c.ProjectID, string(c.Kind), string(c.Action),
|
||||
string(sourceJSON), c.TargetID, c.ProposedContent, c.MergedContent, c.Reason,
|
||||
string(evidenceJSON), c.RuleVersion, c.ScoreMin, c.ScoreMax, c.Status,
|
||||
c.IdempotencyKey, formatTime(c.CreatedAt))
|
||||
c.IdempotencyKey, c.RunID, formatTime(c.CreatedAt))
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("memory: insert tidy candidate: %w", err)
|
||||
}
|
||||
@@ -174,13 +202,25 @@ func (s *Store) persistTidyRun(ctx context.Context, run TidyRun) error {
|
||||
// 非正 limit 取 defaultTidyCandidateLimit)。它是读侧入口:候选正文含隐私,
|
||||
// 只应由 owner-only 的读侧使用(design/23 §11)。
|
||||
func (s *Store) ListTidyCandidates(ctx context.Context, status string, limit int) ([]TidyCandidate, error) {
|
||||
return s.listTidyCandidates(ctx, status, "", limit)
|
||||
}
|
||||
|
||||
// ListTidyCandidatesByRun 返回某一轮整理产出的候选(status 默认 pending)。
|
||||
// runID 为空串表示**不按轮次筛** —— 即「全部待定应用」那一档(切片 TIDY-1)。
|
||||
func (s *Store) ListTidyCandidatesByRun(ctx context.Context, runID, status string, limit int) ([]TidyCandidate, error) {
|
||||
return s.listTidyCandidates(ctx, status, runID, limit)
|
||||
}
|
||||
|
||||
// listTidyCandidates 是上面两个读侧入口的共用实现:runID 为空串时走「不筛轮次」
|
||||
// 的 SQL 分支(`? = ” OR run_id = ?`),因此只有一个查询形状要维护。
|
||||
func (s *Store) listTidyCandidates(ctx context.Context, status, runID string, limit int) ([]TidyCandidate, error) {
|
||||
if strings.TrimSpace(status) == "" {
|
||||
status = tidyCandidatePending
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = defaultTidyCandidateLimit
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, listTidyCandidatesSQL, status, limit)
|
||||
rows, err := s.db.QueryContext(ctx, listTidyCandidatesByRunSQL, status, runID, runID, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: list tidy candidates: %w", err)
|
||||
}
|
||||
@@ -200,6 +240,73 @@ func (s *Store) ListTidyCandidates(ctx context.Context, status string, limit int
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetTidyCandidate 按 id 读一条候选(含已决定的行)。不存在时返回 ErrNotFound。
|
||||
func (s *Store) GetTidyCandidate(ctx context.Context, id string) (TidyCandidate, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return TidyCandidate{}, ErrNotFound
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, selectTidyCandidateSQL, id)
|
||||
c, err := scanTidyCandidate(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TidyCandidate{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return TidyCandidate{}, fmt.Errorf("memory: get tidy candidate: %w", err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// countTidyCandidates 数某状态下(可选按轮次)的候选总数。批量应用用它并把
|
||||
// 「真总数 vs 本轮应用上限」如实回报给调用方(超限绝不静默)。
|
||||
func (s *Store) countTidyCandidates(ctx context.Context, status, runID string) (int, error) {
|
||||
if strings.TrimSpace(status) == "" {
|
||||
status = tidyCandidatePending
|
||||
}
|
||||
var total int
|
||||
if err := s.db.QueryRowContext(ctx, countTidyCandidatesByRunSQL, status, runID, runID).
|
||||
Scan(&total); err != nil {
|
||||
return 0, fmt.Errorf("memory: count tidy candidates: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
// GetTidyRun 按 id 读一条运行留痕。不存在时返回 ErrTidyRunRecordNotFound ——
|
||||
// 面板拿一个拼错的 runId 来批量应用时,必须是「查不到这一轮」而不是「这一轮
|
||||
// 没有待定候选」。
|
||||
func (s *Store) GetTidyRun(ctx context.Context, id string) (TidyRun, error) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return TidyRun{}, ErrTidyRunRecordNotFound
|
||||
}
|
||||
row := s.db.QueryRowContext(ctx, selectTidyRunSQL, id)
|
||||
r, err := scanTidyRun(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return TidyRun{}, ErrTidyRunRecordNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return TidyRun{}, fmt.Errorf("memory: get tidy run: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// LastTidyRunStartedAt 返回最近一轮整理的开始时刻;一次都没跑过时返回零值
|
||||
// 时间与 nil(**不是**错误:那是「还没有历史」这一正常状态,调度据此判断
|
||||
// 是否需要补跑)。
|
||||
func (s *Store) LastTidyRunStartedAt(ctx context.Context) (time.Time, error) {
|
||||
var startedAt string
|
||||
err := s.db.QueryRowContext(ctx, selectLastTidyRunSQL).Scan(&startedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return time.Time{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("memory: read last tidy run: %w", err)
|
||||
}
|
||||
t, err := parseTime(startedAt)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ListTidyRuns 返回整理运行留痕(最新在前,最多 limit 条)。非正 limit 取
|
||||
// 一个保守的默认条数。
|
||||
func (s *Store) ListTidyRuns(ctx context.Context, limit int) ([]TidyRun, error) {
|
||||
@@ -214,26 +321,10 @@ func (s *Store) ListTidyRuns(ctx context.Context, limit int) ([]TidyRun, error)
|
||||
|
||||
out := make([]TidyRun, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
r TidyRun
|
||||
startedAt string
|
||||
finishedAt string
|
||||
reasonsJSON string
|
||||
)
|
||||
if err := rows.Scan(&r.ID, &startedAt, &finishedAt, &r.Scanned, &r.Candidates,
|
||||
&r.Applied, &r.Skipped, &r.Errors, &r.Vetoed, &reasonsJSON, &r.RuleVersion); err != nil {
|
||||
r, err := scanTidyRun(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("memory: scan tidy run: %w", err)
|
||||
}
|
||||
var err error
|
||||
if r.StartedAt, err = parseTime(startedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.FinishedAt, err = parseTime(finishedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(reasonsJSON), &r.VetoReasons); err != nil {
|
||||
return nil, fmt.Errorf("memory: decode tidy veto reasons: %w", err)
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -242,6 +333,31 @@ func (s *Store) ListTidyRuns(ctx context.Context, limit int) ([]TidyRun, error)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// scanTidyRun 读取一行运行留痕:veto_reasons 以 JSON 存储,时间戳为定宽 UTC 文本。
|
||||
func scanTidyRun(sc rowScanner) (TidyRun, error) {
|
||||
var (
|
||||
r TidyRun
|
||||
startedAt string
|
||||
finishedAt string
|
||||
reasonsJSON string
|
||||
)
|
||||
if err := sc.Scan(&r.ID, &startedAt, &finishedAt, &r.Scanned, &r.Candidates,
|
||||
&r.Applied, &r.Skipped, &r.Errors, &r.Vetoed, &reasonsJSON, &r.RuleVersion); err != nil {
|
||||
return TidyRun{}, err
|
||||
}
|
||||
var err error
|
||||
if r.StartedAt, err = parseTime(startedAt); err != nil {
|
||||
return TidyRun{}, err
|
||||
}
|
||||
if r.FinishedAt, err = parseTime(finishedAt); err != nil {
|
||||
return TidyRun{}, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(reasonsJSON), &r.VetoReasons); err != nil {
|
||||
return TidyRun{}, fmt.Errorf("memory: decode tidy veto reasons: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// scanTidyCandidate 读取一行候选:source_ids 与 evidence 以 JSON 存储。
|
||||
func scanTidyCandidate(sc rowScanner) (TidyCandidate, error) {
|
||||
var (
|
||||
@@ -255,7 +371,7 @@ func scanTidyCandidate(sc rowScanner) (TidyCandidate, error) {
|
||||
if err := sc.Scan(&c.ID, &c.PersonaID, &scope, &c.ProjectID, &kind, &action,
|
||||
&sourceJSON, &c.TargetID, &c.ProposedContent, &c.MergedContent, &c.Reason,
|
||||
&evidenceRaw, &c.RuleVersion, &c.ScoreMin, &c.ScoreMax, &c.Status,
|
||||
&c.IdempotencyKey, &createdAt); err != nil {
|
||||
&c.IdempotencyKey, &c.RunID, &createdAt); err != nil {
|
||||
return TidyCandidate{}, err
|
||||
}
|
||||
c.Scope = Scope(scope)
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件承载记忆整理**应用路径**的管理面(切片 TIDY-1;design/23 §7/§8/§11)。
|
||||
// 动因是主人的实测口径:整理只产候选、两天一条没应用(77 条全 pending),而现有
|
||||
// `POST …/tidy/dry-run` 是**只读试算**,改不了任何东西。
|
||||
//
|
||||
// 端点分工(DTO 在 admin_memory_tidy_apply_dto.go,手动真跑在
|
||||
// admin_memory_tidy_run.go —— 拆开只为守 AGENTS §10 的文件行数上限):
|
||||
//
|
||||
// GET /api/v1/admin/memories/candidates 候选列表(含 id/runId/证据)
|
||||
// POST /api/v1/admin/memories/candidates/{id}/apply 按候选 id 应用(写记忆)
|
||||
// POST /api/v1/admin/memories/candidates/apply?runId=&limit= 批量应用(按 run / 全部待定)
|
||||
// POST /api/v1/admin/memories/candidates/{id}/revert 回滚一条已应用候选
|
||||
// POST /api/v1/admin/memories/tidy/run 手动**真跑**一轮(写候选)
|
||||
//
|
||||
// 口径:
|
||||
//
|
||||
// 1. **门同族**:全部走 `writeAuth`(owner|admin),与同族的 `GET /admin/memories`、
|
||||
// `POST …/tidy/dry-run`、`POST …/memories/{id}/revert` 同档 —— 候选正文含隐私,
|
||||
// design/23 §11 的既有取向是「含隐私正文的读侧不给 viewer」。⚠️ §11 表格把
|
||||
// candidates 写回滚标为 owner-only:那只需把 routes.go 里对应行的 `writeAuth`
|
||||
// 换成 `ownerAuth`(一行一处,本文件不用改)。
|
||||
// 2. **写动作落审计**:动作名 `memory.tidy.apply` / `memory.tidy.revert`(design/23
|
||||
// §8 的 `memory.tidy.*` 族),who 来自请求身份;审计只带计数/动作,**不带记忆正文**。
|
||||
// `POST …/tidy/run` 的审计由**领域侧**(TidyService 的 Audit 出口,design §8 的
|
||||
// `memory.tidy.run`,result 里带 trigger=manual + run_id + 计数)写,本文件不重复记
|
||||
// —— 一次手动点击只该有一条 memory.tidy.run。
|
||||
// 3. **诚实失败**:未接线 ⇒ 500 `tidy_unavailable`;候选不存在 ⇒ 404;
|
||||
// runId 查不到 ⇒ 404 `tidy_run_not_found`;配置关闭 ⇒ 409 `tidy_disabled`;
|
||||
// 超时 ⇒ 503 `tidy_timeout`。**绝不**把「没做」回成一份空结果。
|
||||
// 4. **不写第二套语义**:应用/回滚的真实动作全在 `memory.Store` 里(候选状态条件更新
|
||||
// 是幂等闸),端点只做鉴权、取参、投影与审计。
|
||||
// 5. **不加任何上限**:批量上限由领域侧(memory.DefaultTidyApplyBatchLimit 等代码
|
||||
// 默认值)决定,端点只解析 `limit` 并交给领域侧钳位(`.env` 冻结,不新增键)。
|
||||
|
||||
// MemoryTidyRunner 是记忆整理「真跑」的注入出口(切片 TIDY-1)。组合根注入的
|
||||
// 就是低峰心跳跑的那个 `*memory.TidyService`(与试算同一份配置、同一套规则)。
|
||||
//
|
||||
// 它与 `MemoryTidyDryRunner` 分开两个接口是**刻意**的:试算只读、真跑写候选 + 留痕,
|
||||
// 两者可分别接线;未实现本接口的替身(例如只做试算的测试桩)得到的是诚实的
|
||||
// 「真跑未接线」,而不是一条静默成功的假回执。
|
||||
type MemoryTidyRunner interface {
|
||||
// ManualRun 立即跑一轮**真跑**(写候选 + 写运行留痕 + 审计)。配置关闭时
|
||||
// 返回 memory.ErrTidyDisabled。
|
||||
ManualRun(ctx context.Context) (memory.TidyStats, error)
|
||||
}
|
||||
|
||||
// tidyApplyStore 是整理候选写面的窄视图,由 `*memory.Store` 实现。未实现它的
|
||||
// `Deps.Memories` 让本组端点回 500 `tidy_unavailable`(「没接线」与「没有候选」
|
||||
// 必须能分辨,与同族的 ingestReviewStore 同一手法)。
|
||||
type tidyApplyStore interface {
|
||||
ListTidyCandidates(ctx context.Context, status string, limit int) ([]memory.TidyCandidate, error)
|
||||
ApplyTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error)
|
||||
ApplyTidyCandidates(ctx context.Context, runID string, opts memory.TidyApplyOptions) (memory.TidyApplyBatch, error)
|
||||
RevertTidyCandidate(ctx context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error)
|
||||
}
|
||||
|
||||
const (
|
||||
// tidyUnavailableCode 是「整理写面未接线」的线上错误码(与试算组的
|
||||
// tidy_unavailable 同名同义:它们背后是同一件事)。
|
||||
tidyUnavailableCode = "tidy_unavailable"
|
||||
// tidyApplyAction 是应用动作的审计动作名(design/23 §8)。
|
||||
tidyApplyAction = "memory.tidy.apply"
|
||||
// tidyRevertAction 是候选回滚动作的审计动作名。它与 D3 的
|
||||
// `memory.review.revert`(按**记忆 id**撤销一次软合并)**不同**:本条按**候选 id**
|
||||
// 回滚,并把候选退回 pending,因此不复用。
|
||||
tidyRevertAction = "memory.tidy.revert"
|
||||
// tidyRunTimeout 是手动真跑的墙钟上限。真跑要写候选 + 留痕,比试算多几次写入;
|
||||
// 上限存在的意义是「不让卡住的请求占着连接」,超时**如实报错**(503 tidy_timeout)。
|
||||
tidyRunTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// adminTidyCandidatesList 处理 GET /api/v1/admin/memories/candidates。
|
||||
// 只读、不审计(与同族只读管理端点同一档)。
|
||||
func (s *Server) adminTidyCandidatesList(w http.ResponseWriter, r *http.Request) {
|
||||
store := s.tidyCandidates()
|
||||
if store == nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
|
||||
return
|
||||
}
|
||||
// limit 复用同族读侧那一个解析器(默认 100 / 钳到 500 / 非法 400)——
|
||||
// 本切片不复制第三份 limit 解析(AGENTS §10「三次复制必须抽取」,此处是第二次)。
|
||||
limit, ok := parseMemoryReviewLimit(r)
|
||||
if !ok {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
|
||||
return
|
||||
}
|
||||
cands, err := store.ListTidyCandidates(r.Context(), r.URL.Query().Get("status"), limit)
|
||||
if err != nil {
|
||||
s.log.Error("list tidy candidates", "err", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_candidates_failed", "读取整理候选失败"))
|
||||
return
|
||||
}
|
||||
out := AdminTidyCandidatesResponse{
|
||||
Candidates: make([]AdminTidyCandidate, 0, len(cands)),
|
||||
Count: len(cands),
|
||||
}
|
||||
for _, c := range cands {
|
||||
out.Candidates = append(out.Candidates, adminTidyCandidate(c))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// adminTidyCandidateApply 处理 POST /api/v1/admin/memories/candidates/{id}/apply:
|
||||
// 应用**一条**候选(唯一真正改记忆的粒度之一)。三种结果都如实作答:
|
||||
// 不存在 ⇒ 404;已决定/不可应用 ⇒ 200 + `status:skipped`(幂等不是错误);
|
||||
// 真的改了 ⇒ 200 + `status:applied` + `affectedIds`。
|
||||
func (s *Server) adminTidyCandidateApply(w http.ResponseWriter, r *http.Request) {
|
||||
store := s.tidyCandidates()
|
||||
if store == nil {
|
||||
s.auditClaims(r, tidyApplyAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
s.auditClaims(r, tidyApplyAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "candidate id is required"))
|
||||
return
|
||||
}
|
||||
res, err := store.ApplyTidyCandidate(r.Context(), id, memory.TidyApplyOptions{Actor: s.tidyActor(r)})
|
||||
if err != nil {
|
||||
s.auditClaims(r, tidyApplyAction, "error")
|
||||
if errors.Is(err, memory.ErrTidyCandidateNotFound) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "整理候选 %q 不存在", id))
|
||||
return
|
||||
}
|
||||
s.log.Error("apply tidy candidate", "err", err, "candidate", id)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_apply_failed", "应用整理候选失败"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, tidyApplyAction, tidyApplyAuditResult(res))
|
||||
writeJSON(w, http.StatusOK, adminTidyApplyResult(res))
|
||||
}
|
||||
|
||||
// adminTidyCandidatesApplyBatch 处理 POST /api/v1/admin/memories/candidates/apply:
|
||||
// **批量**应用。`runId` 给出时只应用该轮产出的候选(按 run);省略时应用全部待定
|
||||
// (「全部待定应用」这一档)。`limit` 省略 ⇒ 领域侧默认值。
|
||||
//
|
||||
// 逐条结果都在 `results` 里(失败隔离),超限如实回报 `truncated` + `total`。
|
||||
func (s *Server) adminTidyCandidatesApplyBatch(w http.ResponseWriter, r *http.Request) {
|
||||
store := s.tidyCandidates()
|
||||
if store == nil {
|
||||
s.auditClaims(r, tidyApplyAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
|
||||
return
|
||||
}
|
||||
limit, ok := parseTidyApplyLimit(r)
|
||||
if !ok {
|
||||
s.auditClaims(r, tidyApplyAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid limit"))
|
||||
return
|
||||
}
|
||||
runID := r.URL.Query().Get("runId")
|
||||
batch, err := store.ApplyTidyCandidates(r.Context(), runID, memory.TidyApplyOptions{
|
||||
Actor: s.tidyActor(r),
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
s.auditClaims(r, tidyApplyAction, "error")
|
||||
if errors.Is(err, memory.ErrTidyRunRecordNotFound) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "tidy_run_not_found", "整理运行 %q 不存在", runID))
|
||||
return
|
||||
}
|
||||
s.log.Error("apply tidy candidates", "err", err, "run_id", runID)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_apply_failed", "批量应用整理候选失败"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, tidyApplyAction, tidyBatchAuditResult(batch))
|
||||
writeJSON(w, http.StatusOK, adminTidyApplyBatch(batch))
|
||||
}
|
||||
|
||||
// adminTidyCandidateRevert 处理 POST /api/v1/admin/memories/candidates/{id}/revert:
|
||||
// 回滚一条已应用候选(记忆行复原 + 候选退回 pending,行永不物理删除,design §8)。
|
||||
func (s *Server) adminTidyCandidateRevert(w http.ResponseWriter, r *http.Request) {
|
||||
store := s.tidyCandidates()
|
||||
if store == nil {
|
||||
s.auditClaims(r, tidyRevertAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理候选未接线"))
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
s.auditClaims(r, tidyRevertAction, "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "candidate id is required"))
|
||||
return
|
||||
}
|
||||
res, err := store.RevertTidyCandidate(r.Context(), id, memory.TidyApplyOptions{Actor: s.tidyActor(r)})
|
||||
if err != nil {
|
||||
s.auditClaims(r, tidyRevertAction, "error")
|
||||
if errors.Is(err, memory.ErrTidyCandidateNotFound) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "整理候选 %q 不存在", id))
|
||||
return
|
||||
}
|
||||
s.log.Error("revert tidy candidate", "err", err, "candidate", id)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "tidy_revert_failed", "回滚整理候选失败"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, tidyRevertAction, tidyApplyAuditResult(res))
|
||||
writeJSON(w, http.StatusOK, adminTidyApplyResult(res))
|
||||
}
|
||||
|
||||
// tidyCandidates 返回当前接入的整理候选写面;`Memories` 未实现该视图时为 nil。
|
||||
func (s *Server) tidyCandidates() tidyApplyStore {
|
||||
if s.memories == nil {
|
||||
return nil
|
||||
}
|
||||
store, ok := s.memories.(tidyApplyStore)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
// tidyActor 返回决定者的审计标签(落 memory_tidy_candidates.decided_by)。
|
||||
// 与 auditClaimsAccount 的 who 同口径,使面板「谁批的」与审计链能对上。
|
||||
func (s *Server) tidyActor(r *http.Request) string {
|
||||
if claims, ok := auth.ClaimsFromContext(r.Context()); ok {
|
||||
return claims.Sub + ":" + string(claims.Role)
|
||||
}
|
||||
return "anonymous"
|
||||
}
|
||||
|
||||
// parseTidyApplyLimit 解析批量的 limit:缺省/空 ⇒ 0(交给领域侧默认值),
|
||||
// 非整数或负数 ⇒ ok=false(调用方回 400)。上限由**领域侧**钳位,端点不复制那个常量。
|
||||
func parseTidyApplyLimit(r *http.Request) (int, bool) {
|
||||
raw := r.URL.Query().Get("limit")
|
||||
if raw == "" {
|
||||
return 0, true
|
||||
}
|
||||
v, err := strconv.Atoi(raw)
|
||||
if err != nil || v < 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// adminTidyCandidate 投影一条落库候选。SourceIDs 兜成非 null 数组;
|
||||
// 时间为 UTC RFC3339(与同族管理 DTO 同一口径)。
|
||||
func adminTidyCandidate(c memory.TidyCandidate) AdminTidyCandidate {
|
||||
ids := c.SourceIDs
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
return AdminTidyCandidate{
|
||||
ID: c.ID,
|
||||
Action: string(c.Action),
|
||||
Status: c.Status,
|
||||
SourceIDs: ids,
|
||||
TargetID: c.TargetID,
|
||||
PersonaID: c.PersonaID,
|
||||
Scope: string(c.Scope),
|
||||
ProjectID: c.ProjectID,
|
||||
Kind: string(c.Kind),
|
||||
ProposedContent: c.ProposedContent,
|
||||
MergedContent: c.MergedContent,
|
||||
Reason: c.Reason,
|
||||
RuleVersion: c.RuleVersion,
|
||||
RunID: c.RunID,
|
||||
ScoreMin: c.ScoreMin,
|
||||
ScoreMax: c.ScoreMax,
|
||||
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
|
||||
Evidence: c.Evidence,
|
||||
}
|
||||
}
|
||||
|
||||
// adminTidyApplyResult 投影一条应用/回滚结果。
|
||||
func adminTidyApplyResult(res memory.TidyApplyResult) AdminTidyApplyResult {
|
||||
ids := res.AffectedIDs
|
||||
if ids == nil {
|
||||
ids = []string{}
|
||||
}
|
||||
return AdminTidyApplyResult{
|
||||
CandidateID: res.CandidateID,
|
||||
Action: string(res.Action),
|
||||
Status: string(res.Status),
|
||||
Reason: res.Reason,
|
||||
TargetID: res.TargetID,
|
||||
AffectedIDs: ids,
|
||||
}
|
||||
}
|
||||
|
||||
// adminTidyApplyBatch 投影批量回执(results 恒为非 null 数组)。
|
||||
func adminTidyApplyBatch(batch memory.TidyApplyBatch) AdminTidyApplyBatchResponse {
|
||||
out := AdminTidyApplyBatchResponse{
|
||||
RunID: batch.RunID,
|
||||
Limit: batch.Limit,
|
||||
Total: batch.Total,
|
||||
Truncated: batch.Truncated,
|
||||
Applied: batch.Applied,
|
||||
Skipped: batch.Skipped,
|
||||
Failed: batch.Failed,
|
||||
Merged: batch.Merged,
|
||||
Archived: batch.Archived,
|
||||
Reclassified: batch.Reclassified,
|
||||
Results: make([]AdminTidyApplyResult, 0, len(batch.Results)),
|
||||
}
|
||||
for _, res := range batch.Results {
|
||||
out.Results = append(out.Results, adminTidyApplyResult(res))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// tidyApplyAuditResult 渲染单条应用/回滚的审计 result。它**以 ok 开头**(与
|
||||
// 同族「result=ok/error」的既有口径一致,面板按前缀就能分桶),其后才挂动作与
|
||||
// 结果类别、受影响行数 —— 绝不带记忆正文或候选正文(design §8)。
|
||||
func tidyApplyAuditResult(res memory.TidyApplyResult) string {
|
||||
return "ok action=" + string(res.Action) +
|
||||
" status=" + string(res.Status) +
|
||||
" reason=" + res.Reason +
|
||||
" affected=" + strconv.Itoa(len(res.AffectedIDs))
|
||||
}
|
||||
|
||||
// tidyBatchAuditResult 渲染批量应用的审计 result(同样以 ok 开头):带真总数与
|
||||
// 超限标记,因此审计能回答「这一轮到底批了多少、有没有被上限截住」。
|
||||
func tidyBatchAuditResult(batch memory.TidyApplyBatch) string {
|
||||
return "ok run_id=" + batch.RunID +
|
||||
" limit=" + strconv.Itoa(batch.Limit) +
|
||||
" total=" + strconv.Itoa(batch.Total) +
|
||||
" truncated=" + strconv.FormatBool(batch.Truncated) +
|
||||
" applied=" + strconv.Itoa(batch.Applied) +
|
||||
" skipped=" + strconv.Itoa(batch.Skipped) +
|
||||
" failed=" + strconv.Itoa(batch.Failed)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件是记忆整理应用路径的**线上契约形状**(DTO 与投影;切片 TIDY-1)。
|
||||
// 端点与鉴权/审计在 admin_memory_tidy_apply.go,手动真跑在 admin_memory_tidy_run.go。
|
||||
// 拆开只为守 AGENTS §10 的文件行数上限(≤500 行),不改变任何口径。
|
||||
|
||||
// AdminTidyCandidate 是一条落库候选的线上投影(契约里新增的读侧形状)。
|
||||
// 与试算候选(AdminTidyDryRunCandidate)的区别:这里**有 id / status / runId /
|
||||
// createdAt** —— 它们是「拿这条去应用/回滚」所必需的键。
|
||||
type AdminTidyCandidate struct {
|
||||
ID string `json:"id"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
SourceIDs []string `json:"sourceIds"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
PersonaID string `json:"personaId"`
|
||||
Scope string `json:"scope"`
|
||||
ProjectID string `json:"projectId,omitempty"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
ProposedContent string `json:"proposedContent,omitempty"`
|
||||
MergedContent string `json:"mergedContent,omitempty"`
|
||||
Reason string `json:"reason"`
|
||||
RuleVersion string `json:"ruleVersion"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
ScoreMin float64 `json:"scoreMin"`
|
||||
ScoreMax float64 `json:"scoreMax"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Evidence memory.TidyEvidence `json:"evidence"`
|
||||
}
|
||||
|
||||
// AdminTidyCandidatesResponse 是候选列表的线上结构。`candidates` 恒为非 null 数组。
|
||||
type AdminTidyCandidatesResponse struct {
|
||||
Candidates []AdminTidyCandidate `json:"candidates"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// AdminTidyApplyResult 是一条候选的应用/回滚回执(领域结构直接投影:它是
|
||||
// 纯数据、无隐私之外的派生逻辑)。
|
||||
type AdminTidyApplyResult struct {
|
||||
CandidateID string `json:"candidateId"`
|
||||
Action string `json:"action"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
TargetID string `json:"targetId,omitempty"`
|
||||
AffectedIDs []string `json:"affectedIds"`
|
||||
}
|
||||
|
||||
// AdminTidyApplyBatchResponse 是批量应用回执。`total` 是命中筛选的**真**待定候选数,
|
||||
// `applied+skipped+failed` 只覆盖 `limit` 之内处理过的那些 —— 两者不相等时
|
||||
// `truncated=true`(超限绝不静默)。
|
||||
type AdminTidyApplyBatchResponse struct {
|
||||
RunID string `json:"runId,omitempty"`
|
||||
Limit int `json:"limit"`
|
||||
Total int `json:"total"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Applied int `json:"applied"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Merged int `json:"merged"`
|
||||
Archived int `json:"archived"`
|
||||
Reclassified int `json:"reclassified"`
|
||||
Results []AdminTidyApplyResult `json:"results"`
|
||||
}
|
||||
|
||||
// AdminTidyRunResponse 是手动**真跑**一轮的回执(契约里新增的写面形状):
|
||||
// 「新增 / 合并 / 软删 各多少 + 耗时」就是 `candidatesCreated` /
|
||||
// `candidatesByAction{merge|archive}` / `durationMs`。
|
||||
//
|
||||
// 注意它是**建议侧**计数:真跑只产候选、写运行留痕,**不自动应用**(design §7 的
|
||||
// 人工在环)。要落地改动,接着调 candidates 的 apply / 批量 apply。
|
||||
type AdminTidyRunResponse struct {
|
||||
RunID string `json:"runId"`
|
||||
Trigger string `json:"trigger"`
|
||||
RuleVersion string `json:"ruleVersion"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
Partitions int `json:"partitions"`
|
||||
Scanned int `json:"scanned"`
|
||||
Compared int `json:"compared"`
|
||||
Pairs int `json:"pairs"`
|
||||
Vetoed int `json:"vetoed"`
|
||||
// Errors 是本轮的失败计数(分区失败 + 候选写入失败):非 0 时面板要如实显示。
|
||||
Errors int `json:"errors"`
|
||||
// Truncated 列出被护栏(扫描/比对/候选上限或时间预算)截断的分区标签。
|
||||
Truncated []string `json:"truncated,omitempty"`
|
||||
// VetoReasons 是本轮否决原因分布。
|
||||
VetoReasons map[string]int `json:"vetoReasons"`
|
||||
// CandidatesCreated 是本次**新建**的候选数;CandidatesDuplicate 是被幂等键
|
||||
// 挡下(同一批数据重复跑)的数量。
|
||||
CandidatesCreated int `json:"candidatesCreated"`
|
||||
CandidatesDuplicate int `json:"candidatesDuplicate"`
|
||||
// CandidatesByAction 是新建候选按动作的分布(merge / link / archive / reclassify)。
|
||||
CandidatesByAction map[string]int `json:"candidatesByAction"`
|
||||
}
|
||||
@@ -0,0 +1,470 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件锁定记忆整理**应用路径 + 手动真跑**的管理面(切片 TIDY-1)。五条判据:
|
||||
//
|
||||
// ① 候选列表:投影带 id/status/runId/createdAt(应用所需的键)、`candidates`
|
||||
// 恒为非 null 数组、limit 默认/钳位/非法值三态;
|
||||
// ② 单条应用:applied/skipped 都 200(幂等不是错误)、候选不存在 ⇒ 404、
|
||||
// 存储报错 ⇒ 500,**成功与失败都落审计** `memory.tidy.apply`;
|
||||
// ③ 批量应用:runId/limit 如实透传、超限回执带 truncated+total、未知 runId ⇒
|
||||
// 404 `tidy_run_not_found`;
|
||||
// ④ 回滚:200 + 落审计 `memory.tidy.revert`;
|
||||
// ⑤ 手动真跑:回执含「新增/合并/软删 各多少 + 耗时」;未接线真跑 ⇒ 500
|
||||
// `tidy_run_unavailable`(只读试算の替身不得谎报跑过);关闭 ⇒ 409 `tidy_disabled`;
|
||||
// ⑥ 门:匿名 401 / viewer 403 / owner|admin 200。
|
||||
|
||||
// fakeTidyApply 是整理候选写面的替身:既满足 Deps.Memories(MemoryStore),
|
||||
// 又实现管理面要的窄视图(tidyApplyStore)。
|
||||
type fakeTidyApply struct {
|
||||
cands []memory.TidyCandidate
|
||||
listErr error
|
||||
listStatus string
|
||||
listLimit int
|
||||
|
||||
applyResult memory.TidyApplyResult
|
||||
applyErr error
|
||||
applyID string
|
||||
applyOpts memory.TidyApplyOptions
|
||||
|
||||
batchResult memory.TidyApplyBatch
|
||||
batchErr error
|
||||
batchRunID string
|
||||
batchOpts memory.TidyApplyOptions
|
||||
|
||||
revertResult memory.TidyApplyResult
|
||||
revertErr error
|
||||
revertID string
|
||||
revertOpts memory.TidyApplyOptions
|
||||
}
|
||||
|
||||
func (f *fakeTidyApply) List(context.Context) ([]memory.MemoryItem, error) {
|
||||
return []memory.MemoryItem{{ID: "m1", Scope: memory.ScopeUser, Content: "一条记忆"}}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTidyApply) ListTidyCandidates(_ context.Context, status string, limit int) ([]memory.TidyCandidate, error) {
|
||||
f.listStatus, f.listLimit = status, limit
|
||||
return f.cands, f.listErr
|
||||
}
|
||||
|
||||
func (f *fakeTidyApply) ApplyTidyCandidate(_ context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error) {
|
||||
f.applyID, f.applyOpts = id, opts
|
||||
return f.applyResult, f.applyErr
|
||||
}
|
||||
|
||||
func (f *fakeTidyApply) ApplyTidyCandidates(_ context.Context, runID string, opts memory.TidyApplyOptions) (memory.TidyApplyBatch, error) {
|
||||
f.batchRunID, f.batchOpts = runID, opts
|
||||
return f.batchResult, f.batchErr
|
||||
}
|
||||
|
||||
func (f *fakeTidyApply) RevertTidyCandidate(_ context.Context, id string, opts memory.TidyApplyOptions) (memory.TidyApplyResult, error) {
|
||||
f.revertID, f.revertOpts = id, opts
|
||||
return f.revertResult, f.revertErr
|
||||
}
|
||||
|
||||
// fakeTidyRunner 同时实现试算(MemoryTidyDryRunner)与真跑(MemoryTidyRunner),
|
||||
// 使「真跑入口」这条断言能在不碰真实 store 的情况下验投影与错误映射。
|
||||
type fakeTidyRunner struct {
|
||||
stats memory.TidyStats
|
||||
err error
|
||||
calls int
|
||||
dryRuns int
|
||||
}
|
||||
|
||||
func (f *fakeTidyRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
|
||||
f.dryRuns++
|
||||
return memory.TidyDryRun{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTidyRunner) StartDryRun(time.Duration) (memory.TidyDryRunHandle, error) {
|
||||
return memory.TidyDryRunHandle{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeTidyRunner) DryRunResult(string) (memory.TidyAsyncDryRun, error) {
|
||||
return memory.TidyAsyncDryRun{}, memory.ErrTidyRunNotFound
|
||||
}
|
||||
|
||||
func (f *fakeTidyRunner) ManualRun(context.Context) (memory.TidyStats, error) {
|
||||
f.calls++
|
||||
return f.stats, f.err
|
||||
}
|
||||
|
||||
// dryRunOnlyRunner 是**只做试算**的替身:真跑入口必须如实回「未接线」,
|
||||
// 不许因为试算可用就假装跑过。
|
||||
type dryRunOnlyRunner struct{}
|
||||
|
||||
func (dryRunOnlyRunner) DryRun(context.Context) (memory.TidyDryRun, error) {
|
||||
return memory.TidyDryRun{}, nil
|
||||
}
|
||||
|
||||
func (dryRunOnlyRunner) StartDryRun(time.Duration) (memory.TidyDryRunHandle, error) {
|
||||
return memory.TidyDryRunHandle{}, nil
|
||||
}
|
||||
|
||||
func (dryRunOnlyRunner) DryRunResult(string) (memory.TidyAsyncDryRun, error) {
|
||||
return memory.TidyAsyncDryRun{}, memory.ErrTidyRunNotFound
|
||||
}
|
||||
|
||||
// fixtureTidyApplyCandidate 是一条落库候选(merge:把 mem-a 软并入 mem-b)。
|
||||
func fixtureTidyApplyCandidate() memory.TidyCandidate {
|
||||
return memory.TidyCandidate{
|
||||
ID: "cand-1",
|
||||
PersonaID: "cyrene",
|
||||
Scope: memory.ScopePersona,
|
||||
Kind: memory.KindRelationship,
|
||||
Action: memory.TidyMerge,
|
||||
SourceIDs: []string{"mem-a"},
|
||||
TargetID: "mem-b",
|
||||
RunID: "run-1",
|
||||
ProposedContent: "主人常在深夜与助手聊天,有熬夜习惯",
|
||||
MergedContent: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
|
||||
Reason: "fact_subset",
|
||||
RuleVersion: memory.TidyRuleVersion,
|
||||
ScoreMin: 0.95,
|
||||
ScoreMax: 0.95,
|
||||
Status: "pending",
|
||||
CreatedAt: time.Date(2026, 10, 7, 23, 5, 0, 0, time.UTC),
|
||||
Evidence: memory.TidyEvidence{
|
||||
Similarity: 0.95, RecallMode: "embedding", Containment: "a_subset_b",
|
||||
ContentA: "主人常在深夜与助手聊天,有熬夜习惯",
|
||||
ContentB: "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTidyCandidatesListProjection 钉住①。
|
||||
func TestAdminTidyCandidatesListProjection(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeTidyApply{cands: []memory.TidyCandidate{fixtureTidyApplyCandidate()}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/candidates", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if fake.listStatus != "" || fake.listLimit != memoryReviewDefaultLimit {
|
||||
t.Fatalf("透传 status/limit = %q/%d, want \"\"/%d", fake.listStatus, fake.listLimit, memoryReviewDefaultLimit)
|
||||
}
|
||||
var payload struct {
|
||||
Candidates []map[string]json.RawMessage `json:"candidates"`
|
||||
Count int `json:"count"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
|
||||
t.Fatalf("解析 data: %v (body=%s)", err, body)
|
||||
}
|
||||
if payload.Count != 1 || len(payload.Candidates) != 1 {
|
||||
t.Fatalf("count/candidates = %d/%d, want 1/1", payload.Count, len(payload.Candidates))
|
||||
}
|
||||
want := map[string]string{
|
||||
"id": `"cand-1"`,
|
||||
"action": `"merge"`,
|
||||
"status": `"pending"`,
|
||||
"targetId": `"mem-b"`,
|
||||
"personaId": `"cyrene"`,
|
||||
"scope": `"persona"`,
|
||||
"reason": `"fact_subset"`,
|
||||
"ruleVersion": `"` + memory.TidyRuleVersion + `"`,
|
||||
"runId": `"run-1"`,
|
||||
"createdAt": `"2026-10-07T23:05:00Z"`,
|
||||
"scoreMin": `0.95`,
|
||||
"mergedContent": `"主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"`,
|
||||
}
|
||||
for key, val := range want {
|
||||
raw, ok := payload.Candidates[0][key]
|
||||
if !ok {
|
||||
t.Fatalf("缺键 %q(row=%v)", key, payload.Candidates[0])
|
||||
}
|
||||
if string(raw) != val {
|
||||
t.Fatalf("%s = %s, want %s", key, raw, val)
|
||||
}
|
||||
}
|
||||
if string(payload.Candidates[0]["sourceIds"]) != `["mem-a"]` {
|
||||
t.Fatalf("sourceIds = %s, want [\"mem-a\"]", payload.Candidates[0]["sourceIds"])
|
||||
}
|
||||
|
||||
// 空候选 ⇒ 200 + 非 null 空数组(与「未接线」必须可区分)。
|
||||
empty := &fakeTidyApply{}
|
||||
_, ts2, jwt2 := buildEnv(t, cfgDefault(), Deps{Memories: empty})
|
||||
resp, body = doReq(t, http.MethodGet, ts2.URL+"/api/v1/admin/memories/candidates", nil, signAny(t, jwt2, "acct", auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("空候选 status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var emptyPayload struct {
|
||||
Candidates []map[string]json.RawMessage `json:"candidates"`
|
||||
}
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &emptyPayload); err != nil {
|
||||
t.Fatalf("解析空 data: %v", err)
|
||||
}
|
||||
if emptyPayload.Candidates == nil {
|
||||
t.Fatal("空候选必须是 [],不能是 null")
|
||||
}
|
||||
|
||||
// 非法 limit ⇒ 400。
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/candidates?limit=abc", nil, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
|
||||
t.Fatalf("limit=abc ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
|
||||
}
|
||||
// 未接线(存储没有候选视图)⇒ 500 tidy_unavailable,绝不回空数组。
|
||||
_, bare, bareJWT := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}})
|
||||
resp, body = doReq(t, http.MethodGet, bare.URL+"/api/v1/admin/memories/candidates", nil, signAny(t, bareJWT, "acct", auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != tidyUnavailableCode {
|
||||
t.Fatalf("未接线列表 ⇒ %d/%s, want 500/%s", resp.StatusCode, body, tidyUnavailableCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTidyCandidateApplyOutcomesAndAudit 钉住②。
|
||||
func TestAdminTidyCandidateApplyOutcomesAndAudit(t *testing.T) {
|
||||
t.Parallel()
|
||||
audit := &recordingAudit{}
|
||||
fake := &fakeTidyApply{applyResult: memory.TidyApplyResult{
|
||||
CandidateID: "cand-1", Action: memory.TidyMerge, Status: memory.TidyApplyApplied,
|
||||
TargetID: "mem-b", AffectedIDs: []string{"mem-a"},
|
||||
}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/cand-1/apply", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("apply status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if fake.applyID != "cand-1" || fake.applyOpts.Actor != "acct:owner" {
|
||||
t.Fatalf("透传 id/Actor = %q/%q, want cand-1/acct:owner", fake.applyID, fake.applyOpts.Actor)
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
|
||||
t.Fatalf("解析 data: %v", err)
|
||||
}
|
||||
if string(payload["status"]) != `"applied"` || string(payload["affectedIds"]) != `["mem-a"]` {
|
||||
t.Fatalf("回执 = %s, want status=applied affectedIds=[mem-a]", decodeBody(t, body)["data"])
|
||||
}
|
||||
if len(audit.entries) != 1 || audit.entries[0].What != tidyApplyAction || !strings.HasPrefix(audit.entries[0].Result, "ok") {
|
||||
t.Fatalf("审计 = %+v, want 一条 %s/ok", audit.entries, tidyApplyAction)
|
||||
}
|
||||
// 审计不得带记忆正文。
|
||||
if strings.Contains(audit.entries[0].Result, "熬夜") || strings.Contains(audit.entries[0].Result, "mem-a") {
|
||||
t.Fatalf("审计 result 带了正文/行 id:%q", audit.entries[0].Result)
|
||||
}
|
||||
|
||||
// skipped(幂等命中)也是 200:它不是错误。
|
||||
fake.applyResult = memory.TidyApplyResult{
|
||||
CandidateID: "cand-1", Action: memory.TidyMerge, Status: memory.TidyApplySkipped,
|
||||
Reason: "already_decided", AffectedIDs: []string{},
|
||||
}
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/cand-1/apply", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("skipped ⇒ %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(decodeBody(t, body)["data"]), `"reason":"already_decided"`) {
|
||||
t.Fatalf("skipped 回执缺 reason:%s", decodeBody(t, body)["data"])
|
||||
}
|
||||
|
||||
// 候选不存在 ⇒ 404 not_found,且**失败也落审计**。
|
||||
fake.applyResult = memory.TidyApplyResult{}
|
||||
fake.applyErr = memory.ErrTidyCandidateNotFound
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/nope/apply", nil, tok)
|
||||
if resp.StatusCode != http.StatusNotFound || mediaErrorCode(t, body) != "not_found" {
|
||||
t.Fatalf("不存在的候选 ⇒ %d/%s, want 404/not_found", resp.StatusCode, body)
|
||||
}
|
||||
if len(audit.entries) != 3 || audit.entries[2].Result != "error" {
|
||||
t.Fatalf("失败后的审计 = %+v, want 一条 error", audit.entries)
|
||||
}
|
||||
|
||||
// 存储报错 ⇒ 500(不谎报成功)。
|
||||
fake.applyErr = errors.New("db exploded")
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/cand-1/apply", nil, tok)
|
||||
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != "tidy_apply_failed" {
|
||||
t.Fatalf("存储报错 ⇒ %d/%s, want 500/tidy_apply_failed", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTidyCandidatesApplyBatch 钉住③。
|
||||
func TestAdminTidyCandidatesApplyBatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
audit := &recordingAudit{}
|
||||
fake := &fakeTidyApply{batchResult: memory.TidyApplyBatch{
|
||||
RunID: "run-1", Limit: 2, Total: 5, Truncated: true,
|
||||
Applied: 2, Archived: 2,
|
||||
Results: []memory.TidyApplyResult{{
|
||||
CandidateID: "c1", Action: memory.TidyArchive, Status: memory.TidyApplyApplied,
|
||||
AffectedIDs: []string{"m1"},
|
||||
}},
|
||||
}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost,
|
||||
ts.URL+"/api/v1/admin/memories/candidates/apply?runId=run-1&limit=2", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("batch status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if fake.batchRunID != "run-1" || fake.batchOpts.Limit != 2 || fake.batchOpts.Actor != "acct:owner" {
|
||||
t.Fatalf("透传 runId/limit/Actor = %q/%d/%q", fake.batchRunID, fake.batchOpts.Limit, fake.batchOpts.Actor)
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
|
||||
t.Fatalf("解析 data: %v", err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"runId": `"run-1"`, "limit": `2`, "total": `5`, "truncated": `true`,
|
||||
"applied": `2`, "skipped": `0`, "archived": `2`, "merged": `0`,
|
||||
} {
|
||||
if string(payload[key]) != want {
|
||||
t.Fatalf("%s = %s, want %s", key, payload[key], want)
|
||||
}
|
||||
}
|
||||
if len(audit.entries) != 1 || audit.entries[0].What != tidyApplyAction || !strings.HasPrefix(audit.entries[0].Result, "ok") {
|
||||
t.Fatalf("批量审计 = %+v, want 一条 %s/ok", audit.entries, tidyApplyAction)
|
||||
}
|
||||
|
||||
// 省略 runId ⇒ 「全部待定应用」这一档(runId 透传空串)。
|
||||
if _, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/apply", nil, tok); fake.batchRunID != "" {
|
||||
t.Fatalf("省略 runId 时透传 = %q, want 空串(全部待定)(body=%s)", fake.batchRunID, body)
|
||||
}
|
||||
// 未知 runId ⇒ 404 tidy_run_not_found(不是「这一轮没有候选」)。
|
||||
fake.batchErr = memory.ErrTidyRunRecordNotFound
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/apply?runId=nope", nil, tok)
|
||||
fake.batchErr = nil
|
||||
if resp.StatusCode != http.StatusNotFound || mediaErrorCode(t, body) != "tidy_run_not_found" {
|
||||
t.Fatalf("未知 runId ⇒ %d/%s, want 404/tidy_run_not_found", resp.StatusCode, body)
|
||||
}
|
||||
// 非法 limit ⇒ 400。
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/apply?limit=-3", nil, tok)
|
||||
if resp.StatusCode != http.StatusBadRequest || mediaErrorCode(t, body) != "bad_request" {
|
||||
t.Fatalf("limit=-3 ⇒ %d/%s, want 400/bad_request", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTidyCandidateRevertOutcomes 钉住④。
|
||||
func TestAdminTidyCandidateRevertOutcomes(t *testing.T) {
|
||||
t.Parallel()
|
||||
audit := &recordingAudit{}
|
||||
fake := &fakeTidyApply{revertResult: memory.TidyApplyResult{
|
||||
CandidateID: "cand-1", Action: memory.TidyMerge, Status: memory.TidyApplyApplied,
|
||||
AffectedIDs: []string{"mem-a"},
|
||||
}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake, AuditStore: audit})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/cand-1/revert", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("revert status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if fake.revertID != "cand-1" {
|
||||
t.Fatalf("透传 id = %q, want cand-1", fake.revertID)
|
||||
}
|
||||
if len(audit.entries) != 1 || audit.entries[0].What != tidyRevertAction || !strings.HasPrefix(audit.entries[0].Result, "ok") {
|
||||
t.Fatalf("审计 = %+v, want 一条 %s/ok", audit.entries, tidyRevertAction)
|
||||
}
|
||||
// 未接线 ⇒ 500 tidy_unavailable。
|
||||
_, bare, bareJWT := buildEnv(t, cfgDefault(), Deps{Memories: listOnlyMemories{}})
|
||||
resp, body = doReq(t, http.MethodPost, bare.URL+"/api/v1/admin/memories/candidates/cand-1/revert", nil,
|
||||
signAny(t, bareJWT, "acct", auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != tidyUnavailableCode {
|
||||
t.Fatalf("未接线 revert ⇒ %d/%s, want 500/%s", resp.StatusCode, body, tidyUnavailableCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminMemoryTidyRunReceipt 钉住⑤:真跑回执含「新增/合并/软删 + 耗时」。
|
||||
func TestAdminMemoryTidyRunReceipt(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := &fakeTidyRunner{stats: memory.TidyStats{
|
||||
RunID: "run-9", Partitions: 4, Scanned: 1191, Compared: 1191, Pairs: 77,
|
||||
Candidates: 77, Duplicates: 3, Vetoed: 12, DurationMS: 89900,
|
||||
VetoReasons: map[string]int{"temporal": 7, "scope": 5},
|
||||
ByAction: map[string]int{"merge": 30, "link": 40, "archive": 5, "reclassify": 2},
|
||||
Truncated: []string{"cyrene/persona/(compare)"},
|
||||
}}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{MemoryTidy: runner})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/run", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("tidy run status = %d, want 200 (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
if runner.calls != 1 {
|
||||
t.Fatalf("真跑调用次数 = %d, want 1", runner.calls)
|
||||
}
|
||||
var payload map[string]json.RawMessage
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &payload); err != nil {
|
||||
t.Fatalf("解析 data: %v", err)
|
||||
}
|
||||
for key, want := range map[string]string{
|
||||
"runId": `"run-9"`, "trigger": `"manual"`, "durationMs": `89900`,
|
||||
"scanned": `1191`, "candidatesCreated": `77`, "candidatesDuplicate": `3`,
|
||||
"errors": `0`, "partitions": `4`,
|
||||
} {
|
||||
if string(payload[key]) != want {
|
||||
t.Fatalf("%s = %s, want %s", key, payload[key], want)
|
||||
}
|
||||
}
|
||||
if got := string(payload["candidatesByAction"]); got != `{"archive":5,"link":40,"merge":30,"reclassify":2}` {
|
||||
t.Fatalf("candidatesByAction = %s", got)
|
||||
}
|
||||
if got := string(payload["truncated"]); got != `["cyrene/persona/(compare)"]` {
|
||||
t.Fatalf("truncated = %s(截断必须如实上线)", got)
|
||||
}
|
||||
|
||||
// 关闭 ⇒ 409 tidy_disabled(显式选择,不是故障)。
|
||||
runner.err = memory.ErrTidyDisabled
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/run", nil, tok)
|
||||
runner.err = nil
|
||||
if resp.StatusCode != http.StatusConflict || mediaErrorCode(t, body) != "tidy_disabled" {
|
||||
t.Fatalf("关闭时 ⇒ %d/%s, want 409/tidy_disabled", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// 只做试算的替身(未实现真跑)⇒ 500 tidy_run_unavailable,绝不假装跑过。
|
||||
_, only, onlyJWT := buildEnv(t, cfgDefault(), Deps{MemoryTidy: dryRunOnlyRunner{}})
|
||||
resp, body = doReq(t, http.MethodPost, only.URL+"/api/v1/admin/memories/tidy/run", nil,
|
||||
signAny(t, onlyJWT, "acct", auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != "tidy_run_unavailable" {
|
||||
t.Fatalf("真跑未接线 ⇒ %d/%s, want 500/tidy_run_unavailable", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
// 整理服务完全未接线 ⇒ 500 tidy_unavailable。
|
||||
_, none, noneJWT := buildEnv(t, cfgDefault(), Deps{})
|
||||
resp, body = doReq(t, http.MethodPost, none.URL+"/api/v1/admin/memories/tidy/run", nil,
|
||||
signAny(t, noneJWT, "acct", auth.RoleOwner))
|
||||
if resp.StatusCode != http.StatusInternalServerError || mediaErrorCode(t, body) != tidyUnavailableCode {
|
||||
t.Fatalf("未接线 ⇒ %d/%s, want 500/%s", resp.StatusCode, body, tidyUnavailableCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminTidyApplyRBAC 钉住⑥:门与同族管理面一致(匿名 401 / viewer 403 /
|
||||
// owner|admin 200)。
|
||||
func TestAdminTidyApplyRBAC(t *testing.T) {
|
||||
t.Parallel()
|
||||
fake := &fakeTidyApply{}
|
||||
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: fake})
|
||||
paths := []struct{ method, path string }{
|
||||
{http.MethodGet, "/api/v1/admin/memories/candidates"},
|
||||
{http.MethodPost, "/api/v1/admin/memories/candidates/cand-1/apply"},
|
||||
{http.MethodPost, "/api/v1/admin/memories/candidates/apply"},
|
||||
{http.MethodPost, "/api/v1/admin/memories/candidates/cand-1/revert"},
|
||||
}
|
||||
for _, p := range paths {
|
||||
if resp, _ := doReq(t, p.method, ts.URL+p.path, nil, ""); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("%s %s 匿名 status = %d, want 401", p.method, p.path, resp.StatusCode)
|
||||
}
|
||||
viewer := signAny(t, jwt, "acct", auth.RoleViewer)
|
||||
if resp, _ := doReq(t, p.method, ts.URL+p.path, nil, viewer); resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("%s %s viewer status = %d, want 403", p.method, p.path, resp.StatusCode)
|
||||
}
|
||||
admin := signAny(t, jwt, "acct", auth.RoleAdmin)
|
||||
if resp, body := doReq(t, p.method, ts.URL+p.path, nil, admin); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("%s %s admin status = %d, want 200 (body=%s)", p.method, p.path, resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// TestAdminTidyApplyEndToEndWithRealStore 是**该切片的端到端验收**:真
|
||||
// *memory.Store + 真 memory.TidyService + 真 HTTP 入口,走完整条主人的使用路径 ——
|
||||
//
|
||||
// 手动真跑 → 候选落库 → 列候选 → 单条应用(软合并)→ 再按 run 批量应用 → 回滚。
|
||||
//
|
||||
// 断言全部落在**真库**上(行数不变、状态列、merge_count),不是替身转述。
|
||||
func TestAdminTidyApplyEndToEndWithRealStore(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, ":memory:", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
store, err := memory.NewStore(ctx, conn, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("memory.NewStore: %v", err)
|
||||
}
|
||||
// 两条同分区记忆:短句是长句的事实子集 ⇒ 规则判 merge(design/23 §12.1 P1)。
|
||||
const (
|
||||
shortText = "主人常在深夜与助手聊天,有熬夜习惯"
|
||||
longText = "主人常在深夜与助手聊天,有熬夜习惯,作息偏晚"
|
||||
)
|
||||
short, err := store.Add(ctx, memory.MemoryItem{
|
||||
PersonaID: "cyrene", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
|
||||
Content: shortText, Confidence: 0.9,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed short: %v", err)
|
||||
}
|
||||
long, err := store.Add(ctx, memory.MemoryItem{
|
||||
PersonaID: "cyrene", Scope: memory.ScopePersona, Kind: memory.KindRelationship,
|
||||
Content: longText, Confidence: 0.9,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed long: %v", err)
|
||||
}
|
||||
|
||||
// 无 embedder ⇒ 词项余弦召回(生产环境不可用时的降级路径,正好一并走一遍)。
|
||||
svc, err := memory.NewTidyService(memory.TidyConfig{
|
||||
Enabled: true, RecallThreshold: 0.8, ScanLimit: 200,
|
||||
}, memory.TidyDeps{Store: store})
|
||||
if err != nil {
|
||||
t.Fatalf("NewTidyService: %v", err)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Memories: store, MemoryTidy: svc})
|
||||
tok := signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
|
||||
// ① 手动真跑:写候选 + 留痕,并回回执(新增/合并/软删/耗时)。
|
||||
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/tidy/run", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("tidy/run status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var run AdminTidyRunResponse
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &run); err != nil {
|
||||
t.Fatalf("解析真跑回执: %v (%s)", err, body)
|
||||
}
|
||||
if run.RunID == "" || run.Trigger != "manual" {
|
||||
t.Fatalf("真跑回执 = %+v, want 有 runId 且 trigger=manual", run)
|
||||
}
|
||||
if run.CandidatesByAction["merge"] != 1 {
|
||||
t.Fatalf("merge 候选 = %d, want 1(回执=%+v)", run.CandidatesByAction["merge"], run)
|
||||
}
|
||||
|
||||
// ② 列候选:拿 id(apply 的唯一钥匙)。
|
||||
resp, body = doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/memories/candidates", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("candidates status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var list AdminTidyCandidatesResponse
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &list); err != nil {
|
||||
t.Fatalf("解析候选列表: %v", err)
|
||||
}
|
||||
var mergeID string
|
||||
for _, c := range list.Candidates {
|
||||
if c.Action == "merge" && c.TargetID == long.ID && c.RunID == run.RunID {
|
||||
mergeID = c.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if mergeID == "" {
|
||||
t.Fatalf("列表里没有指向超集的 merge 候选:%s", body)
|
||||
}
|
||||
|
||||
// ③ 单条应用:软合并(被吸收行 merged,存活行 merge_count+1,content 不变,行数不变)。
|
||||
rowsBefore := countRealMemoryRows(t, conn)
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/"+mergeID+"/apply", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("apply status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var applied AdminTidyApplyResult
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &applied); err != nil {
|
||||
t.Fatalf("解析应用回执: %v", err)
|
||||
}
|
||||
if applied.Status != string(memory.TidyApplyApplied) || applied.AffectedIDs[0] != short.ID {
|
||||
t.Fatalf("应用回执 = %+v, want applied/affected=%s", applied, short.ID)
|
||||
}
|
||||
assertRealMemoryRow(t, conn, short.ID, "merged", long.ID, shortText)
|
||||
if got := countRealMemoryRows(t, conn); got != rowsBefore {
|
||||
t.Fatalf("软合并改变了行数:%d → %d", rowsBefore, got)
|
||||
}
|
||||
// 重复应用 ⇒ 幂等(skipped),且不产生第二次副作用。
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/"+mergeID+"/apply", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("重复应用 status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
var again AdminTidyApplyResult
|
||||
if err := json.Unmarshal(decodeBody(t, body)["data"], &again); err != nil {
|
||||
t.Fatalf("解析重复应用回执: %v", err)
|
||||
}
|
||||
if again.Status != "skipped" || again.Reason != "already_decided" {
|
||||
t.Fatalf("重复应用 = %+v, want skipped/already_decided", again)
|
||||
}
|
||||
assertRealMergeCount(t, conn, long.ID, 2)
|
||||
|
||||
// ④ 按 run 批量应用:已应用的候选被幂等挡下(skipped),不产生第二次副作用。
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/apply?runId="+run.RunID, nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("批量应用 status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
assertRealMergeCount(t, conn, long.ID, 2)
|
||||
|
||||
// ⑤ 回滚:字段复原、行数不变。
|
||||
resp, body = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories/candidates/"+mergeID+"/revert", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("revert status = %d (body=%s)", resp.StatusCode, body)
|
||||
}
|
||||
assertRealMemoryRow(t, conn, short.ID, "active", "", shortText)
|
||||
assertRealMergeCount(t, conn, long.ID, 1)
|
||||
if got := countRealMemoryRows(t, conn); got != rowsBefore {
|
||||
t.Fatalf("回滚改变了行数:%d → %d", rowsBefore, got)
|
||||
}
|
||||
}
|
||||
|
||||
// countRealMemoryRows 数真库里的 memory_items 行数。
|
||||
func countRealMemoryRows(t *testing.T, conn *sql.DB) int {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := conn.QueryRowContext(context.Background(), `SELECT COUNT(*) FROM memory_items`).Scan(&n); err != nil {
|
||||
t.Fatalf("数行数: %v", err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// assertRealMemoryRow 断言一行记忆的 status/merged_into/content。
|
||||
func assertRealMemoryRow(t *testing.T, conn *sql.DB, id, wantStatus, wantMergedInto, wantContent string) {
|
||||
t.Helper()
|
||||
var status, mergedInto, content string
|
||||
if err := conn.QueryRowContext(context.Background(),
|
||||
`SELECT status, merged_into, content FROM memory_items WHERE id = ?`, id).
|
||||
Scan(&status, &mergedInto, &content); err != nil {
|
||||
t.Fatalf("读记忆行 %s: %v", id, err)
|
||||
}
|
||||
if status != wantStatus || mergedInto != wantMergedInto {
|
||||
t.Fatalf("行 %s = status=%q merged_into=%q, want %q/%q", id, status, mergedInto, wantStatus, wantMergedInto)
|
||||
}
|
||||
if content != wantContent {
|
||||
t.Fatalf("行 %s 的 content 被改了:%q → %q", id, wantContent, content)
|
||||
}
|
||||
}
|
||||
|
||||
// assertRealMergeCount 断言存活行的 merge_count。
|
||||
func assertRealMergeCount(t *testing.T, conn *sql.DB, id string, want int) {
|
||||
t.Helper()
|
||||
var got int
|
||||
if err := conn.QueryRowContext(context.Background(),
|
||||
`SELECT merge_count FROM memory_items WHERE id = ?`, id).Scan(&got); err != nil {
|
||||
t.Fatalf("读记忆行 %s: %v", id, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("行 %s merge_count = %d, want %d", id, got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// 本文件是记忆整理**手动真跑**入口(切片 TIDY-1):
|
||||
// POST /api/v1/admin/memories/tidy/run —— 立刻按同一套规则跑一轮,写候选 + 写运行
|
||||
// 留痕,并把「新增/合并/软删各多少 + 耗时」如实回执。审计由领域侧 TidyService 记
|
||||
// `memory.tidy.run`(一次点击一条,本文件不重复)。
|
||||
|
||||
// adminMemoryTidyRun 处理 POST /api/v1/admin/memories/tidy/run:手动**真跑**一轮
|
||||
// (与心跳、试算走同一套规则与配置;写候选 + 运行留痕)。它是「panic button」:
|
||||
// 主人不必等到 23:00 就能让整理真的产出一批待定候选,再决定应用。
|
||||
//
|
||||
// 审计不在这里写 —— 领域侧 TidyService 已按 design §8 记 `memory.tidy.run`
|
||||
// (result 里带 trigger=manual / run_id / 计数)。一次点击一条审计。
|
||||
func (s *Server) adminMemoryTidyRun(w http.ResponseWriter, r *http.Request) {
|
||||
if s.memoryTidy == nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, tidyUnavailableCode, "记忆整理服务未接线"))
|
||||
return
|
||||
}
|
||||
runner, ok := s.memoryTidy.(MemoryTidyRunner)
|
||||
if !ok {
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "tidy_run_unavailable",
|
||||
Message: "记忆整理真跑入口未接线(只有只读试算可用)",
|
||||
HTTPCode: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), tidyRunTimeout)
|
||||
defer cancel()
|
||||
|
||||
stats, err := runner.ManualRun(ctx)
|
||||
if err != nil {
|
||||
writeError(w, tidyRunAppError(err))
|
||||
return
|
||||
}
|
||||
s.log.Info("memory tidy manual run finished",
|
||||
"run_id", stats.RunID, "scanned", stats.Scanned, "candidates", stats.Candidates,
|
||||
"duplicates", stats.Duplicates, "duration_ms", stats.DurationMS)
|
||||
writeJSON(w, http.StatusOK, adminTidyRunResponse(stats))
|
||||
}
|
||||
|
||||
// tidyRunAppError 把「真跑」失败映射成诚实的错误码。映射表与试算那张
|
||||
// (tidyDryRunAppError)**分开**:试算的 500/409 文案讲的是「没产出候选」,
|
||||
// 真跑讲的是「没跑成」—— 同一类别、不同事实,混用会让排障读到错的那句。
|
||||
func tidyRunAppError(err error) *apperr.AppError {
|
||||
switch {
|
||||
case errors.Is(err, memory.ErrTidyDisabled):
|
||||
return &apperr.AppError{
|
||||
Code: "tidy_disabled",
|
||||
Message: "记忆整理已被配置关闭(MEMORY_TIDY_ENABLED=false)",
|
||||
HTTPCode: http.StatusConflict,
|
||||
}
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return &apperr.AppError{
|
||||
Code: "tidy_timeout",
|
||||
Message: "记忆整理真跑超时(本轮未完整跑完,已跑部分仍写入了留痕)",
|
||||
HTTPCode: http.StatusServiceUnavailable,
|
||||
}
|
||||
default:
|
||||
return apperr.Wrap(apperr.ErrInternal, "tidy_run_failed", "记忆整理真跑失败")
|
||||
}
|
||||
}
|
||||
|
||||
// adminTidyRunResponse 投影真跑回执。
|
||||
func adminTidyRunResponse(stats memory.TidyStats) AdminTidyRunResponse {
|
||||
byAction := stats.ByAction
|
||||
if byAction == nil {
|
||||
byAction = map[string]int{}
|
||||
}
|
||||
reasons := stats.VetoReasons
|
||||
if reasons == nil {
|
||||
reasons = map[string]int{}
|
||||
}
|
||||
return AdminTidyRunResponse{
|
||||
RunID: stats.RunID,
|
||||
Trigger: "manual",
|
||||
RuleVersion: memory.TidyRuleVersion,
|
||||
DurationMs: stats.DurationMS,
|
||||
Partitions: stats.Partitions,
|
||||
Scanned: stats.Scanned,
|
||||
Compared: stats.Compared,
|
||||
Pairs: stats.Pairs,
|
||||
Vetoed: stats.Vetoed,
|
||||
Errors: stats.Errors,
|
||||
Truncated: stats.Truncated,
|
||||
VetoReasons: reasons,
|
||||
CandidatesCreated: stats.Candidates,
|
||||
CandidatesDuplicate: stats.Duplicates,
|
||||
CandidatesByAction: byAction,
|
||||
}
|
||||
}
|
||||
@@ -243,6 +243,18 @@ func (s *Server) registerAdminRoutes(
|
||||
// 它是**写**,因此落审计(动作名 memory.review.revert,见 admin_memory_review.go)。
|
||||
mux.Handle("GET /api/v1/admin/memories/reviews", writeAuth(http.HandlerFunc(s.adminMemoryReviews)))
|
||||
mux.Handle("POST /api/v1/admin/memories/{id}/revert", writeAuth(http.HandlerFunc(s.adminMemoryRevert)))
|
||||
// 记忆整理的**应用路径 + 手动真跑**(切片 TIDY-1,design/23 §7/§8/§11;见
|
||||
// admin_memory_tidy_apply.go)。五条端点补齐「整理真的能用」:此前只有
|
||||
// `POST …/tidy/dry-run`(只读试算),候选落库后**没有任何执行面**,于是 77 条
|
||||
// 全 pending、两天一条没应用。门与同族的 `GET /admin/memories` /
|
||||
// `POST …/memories/{id}/revert` 同档(writeAuth = owner|admin):候选正文含隐私。
|
||||
// 写动作落审计 `memory.tidy.apply` / `memory.tidy.revert`;`…/tidy/run` 的审计
|
||||
// 由领域侧 TidyService 记 `memory.tidy.run`(一次点击一条,不重复)。
|
||||
mux.Handle("GET /api/v1/admin/memories/candidates", writeAuth(http.HandlerFunc(s.adminTidyCandidatesList)))
|
||||
mux.Handle("POST /api/v1/admin/memories/candidates/apply", writeAuth(http.HandlerFunc(s.adminTidyCandidatesApplyBatch)))
|
||||
mux.Handle("POST /api/v1/admin/memories/candidates/{id}/apply", writeAuth(http.HandlerFunc(s.adminTidyCandidateApply)))
|
||||
mux.Handle("POST /api/v1/admin/memories/candidates/{id}/revert", writeAuth(http.HandlerFunc(s.adminTidyCandidateRevert)))
|
||||
mux.Handle("POST /api/v1/admin/memories/tidy/run", writeAuth(http.HandlerFunc(s.adminMemoryTidyRun)))
|
||||
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)))
|
||||
|
||||
Reference in new issue
Block a user