fix(mcp): backfill 按落盘路径归并——同路径重复登记互相覆盖丢笔记(真机实锤丢 20 条)
All checks were successful
Push & PR Check / check (push) Successful in 1m4s

回填改为先解析路径再归并写入(按 note id 排序),同路径多登记合并为一个文件;
新增回归:两登记同路径 → 4 条全归档不覆盖。cargo test notes_ 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
lanrtop 2026-09-11 22:30:57 +09:00
parent 87494ba055
commit 01c9433a08

View File

@ -1125,37 +1125,55 @@ fn backfill_notes_archive(conn: &rusqlite::Connection) -> Result<String, String>
.filter_map(|r| r.ok()) .filter_map(|r| r.ok())
.collect(); .collect();
let mut by_project: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new(); let mut by_project: std::collections::BTreeMap<String, Vec<(i64, String)>> = std::collections::BTreeMap::new();
for (id, pid, source, content, created_at) in &rows { for (id, pid, source, content, created_at) in &rows {
by_project by_project
.entry(pid.clone()) .entry(pid.clone())
.or_default() .or_default()
.push(note_jsonl_line(*id, pid, source, content, created_at)); .push((*id, note_jsonl_line(*id, pid, source, content, created_at)));
} }
let _guard = NOTES_ARCHIVE_LOCK.lock().map_err(|e| e.to_string())?; // 按「解析后的落盘路径」归并——同路径重复登记mcp-single-server 已知遗留)指向同一文件,
// 若按 project_id 逐项目整写会互相覆盖2026-09-11 真机回填实锤:后写覆盖先写丢 20 条)
let mut by_path: std::collections::BTreeMap<std::path::PathBuf, (Vec<String>, Vec<(i64, String)>)> =
std::collections::BTreeMap::new();
let mut report = String::from("## notes 档案回填(全量重建)\n\n"); let mut report = String::from("## notes 档案回填(全量重建)\n\n");
let (mut archived, mut skipped) = (0usize, 0usize); let mut skipped = 0usize;
for (pid, lines) in &by_project { for (pid, entries) in by_project {
match notes_archive_path(conn, pid) { match notes_archive_path(conn, &pid) {
Ok(path) => { Ok(path) => {
if let Some(dir) = path.parent() { let slot = by_path.entry(path).or_default();
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; slot.0.push(pid);
} slot.1.extend(entries);
std::fs::write(&path, lines.join("\n") + "\n").map_err(|e| e.to_string())?;
report.push_str(&format!("- ✅ {}{} 条 → {}\n", pid, lines.len(), path.display()));
archived += 1;
} }
Err(e) => { Err(e) => {
report.push_str(&format!("- ⚠️ {}:跳过({}{} 条仅存 DB\n", pid, e, lines.len())); report.push_str(&format!("- ⚠️ {}:跳过({}{} 条仅存 DB\n", pid, e, entries.len()));
skipped += 1; skipped += 1;
} }
} }
} }
let _guard = NOTES_ARCHIVE_LOCK.lock().map_err(|e| e.to_string())?;
let mut archived_notes = 0usize;
for (path, (pids, mut entries)) in by_path {
entries.sort_by_key(|(id, _)| *id);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let lines: Vec<&str> = entries.iter().map(|(_, l)| l.as_str()).collect();
std::fs::write(&path, lines.join("\n") + "\n").map_err(|e| e.to_string())?;
archived_notes += entries.len();
report.push_str(&format!(
"- ✅ {}{} 条 → {}\n",
pids.join(" + "),
entries.len(),
path.display()
));
}
report.push_str(&format!( report.push_str(&format!(
"\n共 {} 条笔记;{} 个项目已归档,{} 个跳过(跳过项待修复登记路径后重跑即可)。", "\n共 {} 条笔记{} 条已归档,{} 个项目登记被跳过(修复登记路径后重跑即可)。",
rows.len(), rows.len(),
archived, archived_notes,
skipped skipped
)); ));
Ok(report) Ok(report)
@ -1513,6 +1531,36 @@ mod tests {
assert_eq!(count2, 3, "重跑不应翻倍"); assert_eq!(count2, 3, "重跑不应翻倍");
} }
#[test]
fn notes_backfill_merges_duplicate_registrations_same_path() {
// 同路径重复登记mcp-single-server 已知遗留):两条 project 记录指向同一目录,
// 回填必须归并写入而非互相覆盖2026-09-11 真机实锤丢 20 条的回归)
let conn = rusqlite::Connection::open_in_memory().unwrap();
setup_notes_schema(&conn);
let base = temp_base("duppath");
for pid in ["uuid-form", "name-form"] {
conn.execute(
"INSERT INTO project_workspaces (id, win_path) VALUES (?1, ?2)",
rusqlite::params![pid, base.to_str().unwrap()],
)
.unwrap();
}
for i in 0..3 {
conn.execute(
"INSERT INTO project_notes (project_id, content) VALUES ('uuid-form', ?1)",
[format!("a-{i}")],
)
.unwrap();
}
conn.execute("INSERT INTO project_notes (project_id, content) VALUES ('name-form', 'b-0')", []).unwrap();
let report = backfill_notes_archive(&conn).expect("回填应成功");
let file = base.join(".blueprint").join("notes-archive.jsonl");
let text = std::fs::read_to_string(&file).unwrap();
assert_eq!(text.lines().count(), 4, "同路径两登记的笔记应全部归并: {report}");
assert!(report.contains("4 条已归档"), "报告应按笔记数计数: {report}");
}
#[test] #[test]
fn notes_archive_concurrent_appends_no_interleave() { fn notes_archive_concurrent_appends_no_interleave() {
let base = temp_base("concurrent"); let base = temp_base("concurrent");