feat(mcp): notes 档案分片双写 + backfill 自愈(可拆除性修复) #5

Merged
lanrtop merged 2 commits from feat/governance-feedback-loop into master 2026-09-11 21:26:21 +08:00
2 changed files with 260 additions and 1 deletions
Showing only changes of commit 6d02b12e9f - Show all commits

View File

@ -28,7 +28,14 @@ fn validate_registered_path(raw: &str, field: &str) -> Result<PathBuf, String> {
/// 解析项目根目录路径WSL 项目自动转为 Windows UNC 路径) /// 解析项目根目录路径WSL 项目自动转为 Windows UNC 路径)
fn project_root(project_id: &str) -> Result<PathBuf, String> { fn project_root(project_id: &str) -> Result<PathBuf, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?; 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<PathBuf, String> {
let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row( let row: rusqlite::Result<(Option<String>, Option<String>, Option<String>)> = conn.query_row(
"SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1", "SELECT win_path, wsl_path, platform FROM project_workspaces WHERE id = ?1",
[project_id], [project_id],

View File

@ -80,6 +80,11 @@ pub fn tools_list_result() -> Value {
"required": ["project_id", "content"] "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", "name": "get_blueprint_status",
"description": "检测项目蓝图治理状态CONVENTIONS 规则版本是否最新、蓝图内容是否可能落后于代码synced/rules_outdated/content_stale/none。等价于炼境 UI 项目卡片上的蓝图状态徽章。", "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(""); let content = args.get("content").and_then(|v| v.as_str()).unwrap_or("");
append_project_note(pid, content) 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" => { "get_project_snapshots" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); 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; 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<String, String
rusqlite::params![project_id, content], rusqlite::params![project_id, content],
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
Ok("笔记已保存 ✅".to_string()) // 双写到项目分片档案可拆除性失败不回滚笔记——DB 是事实源backfill 可自愈
let note_id = conn.last_insert_rowid();
if let Err(e) = archive_note_append(&conn, note_id) {
db::log_event(
"mcp_tools",
"warn",
&format!("notes 档案双写失败DB 已保存backfill_notes_archive 可自愈): {e}"),
None,
);
return Ok("笔记已保存 ✅(项目档案双写失败已记日志,可调 backfill_notes_archive 自愈)".to_string());
}
Ok("笔记已保存 ✅(已同步项目 .blueprint/notes-archive.jsonl".to_string())
}
// ── notes 档案分片(可拆除性修复)─────────────────────────────────────────────
// project_notes 是全系统唯一不在 git 的原创资产;双写到各项目
// .blueprint/notes-archive.jsonlJSONL 机器可重放重建 DB即「重生配方」
// 归宿按项目分片而非炼境仓库集中存放:路径解析可靠(复用登记路径防护)、
// 可拆除性按项目成立、子项目会话(含 DSH可直读自家档案。
static NOTES_ARCHIVE_LOCK: std::sync::Mutex<()> = 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<std::path::PathBuf, String> {
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<String, String> {
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<String, Vec<String>> = 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<String, String> { fn get_project_snapshots(project_id: &str, days: u32) -> Result<String, String> {
@ -1303,6 +1426,135 @@ mod tests {
} }
} }
/// notes 档案测试用最小 schemain-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] #[test]
fn apply_onboarding_pack_schema_requires_project_id() { fn apply_onboarding_pack_schema_requires_project_id() {
let result = tools_list_result(); let result = tools_list_result();