fix(mcp): backfill 按落盘路径归并——同路径重复登记互相覆盖丢笔记(真机实锤丢 20 条)
All checks were successful
Push & PR Check / check (push) Successful in 1m4s
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:
parent
87494ba055
commit
01c9433a08
@ -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!(
|
report.push_str(&format!(
|
||||||
"\n共 {} 条笔记;{} 个项目已归档,{} 个跳过(跳过项待修复登记路径后重跑即可)。",
|
"- ✅ {}:{} 条 → {}\n",
|
||||||
|
pids.join(" + "),
|
||||||
|
entries.len(),
|
||||||
|
path.display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
report.push_str(&format!(
|
||||||
|
"\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");
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user