Compare commits

..

2 Commits

Author SHA1 Message Date
lanrtop
e345cc6a90 chore(blueprint): notes 档案卡收官——归宿翻转声明 + 验收留痕
All checks were successful
Push & PR Check / check (push) Successful in 1m10s
Push & PR Check / check (pull_request) Successful in 55s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 22:23:02 +09:00
lanrtop
6d02b12e9f feat(mcp): notes 档案分片双写 + backfill_notes_archive 自愈工具
project_notes 是全系统唯一不在 git 的原创资产(可拆除性唯一硬违规)。
append_project_note 现双写到项目 .blueprint/notes-archive.jsonl(JSONL 可重放
重建 DB=重生配方);双写失败不回滚仅记日志,backfill_notes_archive 全量重建自愈。
归宿按项目分片(辩证翻转集中式设计,理由见蓝图卡留痕)。
死登记路径复用 validate_registered_path 防护;project_root 重构出 DI 版。
cargo test 110 全绿(新增 3:双写匹配/回填幂等+死登记跳过/并发不交错)。

Feature-Confirmed: true
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-11 22:22:59 +09:00
3 changed files with 272 additions and 6 deletions

View File

@ -147,12 +147,19 @@ R 规则层=CONVENTIONS 规则库炼境强项业务侧反而隐式E
## 任务卡
### 📋 notes 档案落地(可拆除性修复:双写 + 存量回填)
- status: todo
### notes 档案落地(可拆除性修复:双写 + 存量回填)· done 2026-09-11
- status: done
- complexity: M
- files: src-tauri/src/mcp_tools.rs, src-tauri/src/db.rs
- acceptance: agent_verify: [Tauri command] append_project_note(pid, content) → DB 有记录**且** `.blueprint/notes-archive.jsonl` 尾行 JSON 可解析出同一 content存量回填命令执行后 JSONL 行数 = project_notes 表行数cargo test 覆盖双写 + 并发追加不交错
- 备注project_notes 是全系统唯一不在 git 的原创资产(飞轮全部原料)。格式选 JSONL 而非 md——机器可重放重建 DB才是真「重生配方」。归宿炼境仓库 `.blueprint/notes-archive.jsonl`,沿 usage.json「炼境自动写文件 + chore commit」先例。这即本模块缺口①「结构化落账」的地基
- files: src-tauri/src/mcp_tools.rs, src-tauri/src/mcp_inject.rs原卡列 db.rs 实际未动——无新表,纯双写层)
- acceptance: agent_verify: [Tauri command] append_project_note(pid, content) → DB 有记录**且** `.blueprint/notes-archive.jsonl` 尾行 JSON 可解析出同一 content存量回填命令执行后 JSONL 行数 = project_notes 表行数cargo test 覆盖双写 + 并发追加不交错 ✅110 测试全绿含新增 3双写行匹配 / 回填幂等+死登记跳过 / 8 线程并发不交错)
- **归宿翻转声明(动工前辩证,偏离原卡设计)**:原卡「炼境仓库集中式档案」→ 改为**按项目分片**
(各项目 `.blueprint/notes-archive.jsonl` 存自己的笔记)。理由:① 安装态进程无法可靠定位「炼境仓库」路径;
② 与「资产=纯文件在项目 git、可拆除性按项目」哲学一致透镜节 .blueprint 分片描述);③ 子项目会话
(含 DSH文件直读就地消费自家档案④ usage.json 先例本就是 per-project 写入
- 实现留痕双写失败不回滚笔记DB 事实源)仅 log_event warn`backfill_notes_archive` MCP 工具
全量重建幂等自愈;死登记(空/相对路径)跳过并报告(复用 validate_registered_path 防护,
project_root 重构出依赖注入版 project_root_with_conn
- **待重建终验**MCP 实调append 双写 + backfill 存量 78+ 条笔记落盘)需重建安装炼境后执行
### 📋 /ship 模板体检(第二轮分诊待行动 ×4
- status: todo

View File

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

View File

@ -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<String, String
rusqlite::params![project_id, content],
)
.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> {
@ -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]
fn apply_onboarding_pack_schema_requires_project_id() {
let result = tools_list_result();