diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 6e7bce5..bb48a05 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -1125,37 +1125,55 @@ fn backfill_notes_archive(conn: &rusqlite::Connection) -> Result .filter_map(|r| r.ok()) .collect(); - let mut by_project: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut by_project: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for (id, pid, source, content, created_at) in &rows { by_project .entry(pid.clone()) .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, Vec<(i64, String)>)> = + std::collections::BTreeMap::new(); let mut report = String::from("## notes 档案回填(全量重建)\n\n"); - let (mut archived, mut skipped) = (0usize, 0usize); - for (pid, lines) in &by_project { - match notes_archive_path(conn, pid) { + let mut skipped = 0usize; + for (pid, entries) in by_project { + match notes_archive_path(conn, &pid) { Ok(path) => { - if let Some(dir) = path.parent() { - std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; - } - 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; + let slot = by_path.entry(path).or_default(); + slot.0.push(pid); + slot.1.extend(entries); } Err(e) => { - report.push_str(&format!("- ⚠️ {}:跳过({}),{} 条仅存 DB\n", pid, e, lines.len())); + report.push_str(&format!("- ⚠️ {}:跳过({}),{} 条仅存 DB\n", pid, e, entries.len())); 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!( - "\n共 {} 条笔记;{} 个项目已归档,{} 个跳过(跳过项待修复登记路径后重跑即可)。", + "\n共 {} 条笔记,{} 条已归档,{} 个项目登记被跳过(修复登记路径后重跑即可)。", rows.len(), - archived, + archived_notes, skipped )); Ok(report) @@ -1513,6 +1531,36 @@ mod tests { 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] fn notes_archive_concurrent_appends_no_interleave() { let base = temp_base("concurrent");