diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index 3c145ea..790d7ce 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -28,7 +28,14 @@ fn validate_registered_path(raw: &str, field: &str) -> Result { /// 解析项目根目录路径(WSL 项目自动转为 Windows UNC 路径) fn project_root(project_id: &str) -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; + project_root_with_conn(&conn, project_id) +} +/// project_root 的依赖注入版:连接由调用方传入(业务层复用 + in-memory 测试) +pub(crate) fn project_root_with_conn( + conn: &rusqlite::Connection, + project_id: &str, +) -> Result { let row: rusqlite::Result<(Option, Option, Option)> = conn.query_row( "SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1", [project_id], diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 7451e83..6e7bce5 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -80,6 +80,11 @@ pub fn tools_list_result() -> Value { "required": ["project_id", "content"] } }, + { + "name": "backfill_notes_archive", + "description": "将 project_notes 全量重建到各项目 .blueprint/notes-archive.jsonl 分片档案(可拆除性修复:笔记是全系统唯一不在 git 的原创资产)。幂等,DB 为事实源整文件重写;append_project_note 双写失败后的自愈入口。无参数。", + "inputSchema": { "type": "object", "properties": {}, "required": [] } + }, { "name": "get_blueprint_status", "description": "检测项目蓝图治理状态:CONVENTIONS 规则版本是否最新、蓝图内容是否可能落后于代码(synced/rules_outdated/content_stale/none)。等价于炼境 UI 项目卡片上的蓝图状态徽章。", @@ -386,6 +391,10 @@ pub async fn tools_call(params: Option<&Value>) -> Value { let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); append_project_note(pid, content) } + "backfill_notes_archive" => { + let conn = db::pool().get().map_err(|e| e.to_string())?; + backfill_notes_archive(&conn) + } "get_project_snapshots" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); let days = args.get("days").and_then(|v| v.as_u64()).unwrap_or(30) as u32; @@ -1035,7 +1044,121 @@ fn append_project_note(project_id: &str, content: &str) -> Result = std::sync::Mutex::new(()); + +/// 单条笔记 → JSONL 行(含 project_id,档案自含,脱离文件位置也可重建) +fn note_jsonl_line(id: i64, project_id: &str, source: &str, content: &str, created_at: &str) -> String { + json!({ + "id": id, + "project_id": project_id, + "source": source, + "content": content, + "created_at": created_at, + }) + .to_string() +} + +fn notes_archive_path( + conn: &rusqlite::Connection, + project_id: &str, +) -> Result { + let root = crate::mcp_inject::project_root_with_conn(conn, project_id)?; + Ok(root.join(".blueprint").join("notes-archive.jsonl")) +} + +/// 追加单条到项目分片档案。路径无效(死登记/空路径)返回 Err,由调用方降级处理 +fn archive_note_append(conn: &rusqlite::Connection, note_id: i64) -> Result<(), String> { + let (pid, source, content, created_at): (String, String, String, String) = conn + .query_row( + "SELECT project_id, source, content, created_at FROM project_notes WHERE id = ?1", + [note_id], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)), + ) + .map_err(|e| e.to_string())?; + let path = notes_archive_path(conn, &pid)?; + let _guard = NOTES_ARCHIVE_LOCK.lock().map_err(|e| e.to_string())?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; + } + use std::io::Write; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|e| e.to_string())?; + writeln!(f, "{}", note_jsonl_line(note_id, &pid, &source, &content, &created_at)) + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// 全量重建所有项目的分片档案(幂等,DB 为事实源整文件重写) +fn backfill_notes_archive(conn: &rusqlite::Connection) -> Result { + let mut stmt = conn + .prepare( + "SELECT id, project_id, source, content, created_at + FROM project_notes ORDER BY project_id, id", + ) + .map_err(|e| e.to_string())?; + let rows: Vec<(i64, String, String, String, String)> = stmt + .query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?))) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + + 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)); + } + + let _guard = NOTES_ARCHIVE_LOCK.lock().map_err(|e| e.to_string())?; + 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) { + 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; + } + Err(e) => { + report.push_str(&format!("- ⚠️ {}:跳过({}),{} 条仅存 DB\n", pid, e, lines.len())); + skipped += 1; + } + } + } + report.push_str(&format!( + "\n共 {} 条笔记;{} 个项目已归档,{} 个跳过(跳过项待修复登记路径后重跑即可)。", + rows.len(), + archived, + skipped + )); + Ok(report) } fn get_project_snapshots(project_id: &str, days: u32) -> Result { @@ -1303,6 +1426,135 @@ mod tests { } } + /// notes 档案测试用最小 schema(in-memory 或文件库共用) + fn setup_notes_schema(conn: &rusqlite::Connection) { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS project_notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'mcp', + content TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now', 'localtime')) + ); + CREATE TABLE IF NOT EXISTS project_workspaces ( + id TEXT PRIMARY KEY, profile_id TEXT, + win_path TEXT, wsl_path TEXT, platform TEXT + ); + CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT);", + ) + .unwrap(); + } + + fn temp_base(tag: &str) -> std::path::PathBuf { + let base = std::env::temp_dir().join(format!("lianjing_notes_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&base); + std::fs::create_dir_all(&base).unwrap(); + base + } + + #[test] + fn notes_archive_dual_write_line_matches_db() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + setup_notes_schema(&conn); + let base = temp_base("dual"); + conn.execute( + "INSERT INTO project_workspaces (id, win_path) VALUES ('p1', ?1)", + [base.to_str().unwrap()], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_notes (project_id, content) VALUES ('p1', '含换行\n与中文的内容')", + [], + ) + .unwrap(); + let note_id = conn.last_insert_rowid(); + archive_note_append(&conn, note_id).expect("双写应成功"); + + let file = base.join(".blueprint").join("notes-archive.jsonl"); + let text = std::fs::read_to_string(&file).unwrap(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 1, "应恰好一行"); + let v: Value = serde_json::from_str(lines[0]).expect("尾行应为合法 JSON"); + assert_eq!(v["content"], "含换行\n与中文的内容"); + assert_eq!(v["project_id"], "p1"); + assert_eq!(v["id"], note_id); + } + + #[test] + fn notes_backfill_rebuilds_and_skips_dead_registration() { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + setup_notes_schema(&conn); + let base = temp_base("backfill"); + conn.execute( + "INSERT INTO project_workspaces (id, win_path) VALUES ('good', ?1)", + [base.to_str().unwrap()], + ) + .unwrap(); + // 死登记:空路径(2026-07-15 空路径污染同款场景,必须跳过而非写进 cwd) + conn.execute("INSERT INTO project_workspaces (id, win_path) VALUES ('dead', '')", []).unwrap(); + for i in 0..3 { + conn.execute( + "INSERT INTO project_notes (project_id, content) VALUES ('good', ?1)", + [format!("note-{i}")], + ) + .unwrap(); + } + conn.execute("INSERT INTO project_notes (project_id, content) VALUES ('dead', 'x')", []).unwrap(); + + let report = backfill_notes_archive(&conn).expect("回填应成功"); + let file = base.join(".blueprint").join("notes-archive.jsonl"); + let count = std::fs::read_to_string(&file).unwrap().lines().count(); + assert_eq!(count, 3, "good 项目 JSONL 行数应等于其表行数"); + assert!(report.contains("⚠️ dead"), "死登记应被跳过并报告: {report}"); + + // 幂等:重跑后行数不变(整文件重写而非追加) + backfill_notes_archive(&conn).unwrap(); + let count2 = std::fs::read_to_string(&file).unwrap().lines().count(); + assert_eq!(count2, 3, "重跑不应翻倍"); + } + + #[test] + fn notes_archive_concurrent_appends_no_interleave() { + let base = temp_base("concurrent"); + let db_path = base.join("test.db"); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + setup_notes_schema(&conn); + conn.execute( + "INSERT INTO project_workspaces (id, win_path) VALUES ('p1', ?1)", + [base.to_str().unwrap()], + ) + .unwrap(); + for i in 0..8 { + conn.execute( + "INSERT INTO project_notes (project_id, content) VALUES ('p1', ?1)", + [format!("并发内容较长以放大交错概率-{i}-{}", "x".repeat(500))], + ) + .unwrap(); + } + } + let handles: Vec<_> = (1..=8) + .map(|note_id| { + let p = db_path.clone(); + std::thread::spawn(move || { + let conn = rusqlite::Connection::open(&p).unwrap(); + archive_note_append(&conn, note_id).expect("并发追加应成功"); + }) + }) + .collect(); + for h in handles { + h.join().unwrap(); + } + let file = base.join(".blueprint").join("notes-archive.jsonl"); + let text = std::fs::read_to_string(&file).unwrap(); + let lines: Vec<&str> = text.lines().collect(); + assert_eq!(lines.len(), 8, "8 线程应产出 8 行"); + for l in &lines { + let v: Value = serde_json::from_str(l).expect("每行都应是完整 JSON(无交错)"); + assert_eq!(v["project_id"], "p1"); + } + } + #[test] fn apply_onboarding_pack_schema_requires_project_id() { let result = tools_list_result();