Compare commits
No commits in common. "fb96865c11756f5521862d6358b66d634d04bfa5" and "797b3e50309be390eda55601037313e0c4e3625b" have entirely different histories.
fb96865c11
...
797b3e5030
@ -147,19 +147,12 @@ R 规则层=CONVENTIONS 规则库(炼境强项,业务侧反而隐式)|E
|
|||||||
|
|
||||||
## 任务卡
|
## 任务卡
|
||||||
|
|
||||||
### ✅ notes 档案落地(可拆除性修复:双写 + 存量回填)· done 2026-09-11
|
### 📋 notes 档案落地(可拆除性修复:双写 + 存量回填)
|
||||||
- status: done
|
- status: todo
|
||||||
- complexity: M
|
- complexity: M
|
||||||
- files: src-tauri/src/mcp_tools.rs, src-tauri/src/mcp_inject.rs(原卡列 db.rs 实际未动——无新表,纯双写层)
|
- 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 覆盖双写 + 并发追加不交错 ✅(110 测试全绿含新增 3:双写行匹配 / 回填幂等+死登记跳过 / 8 线程并发不交错)
|
- 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」先例。这即本模块缺口①「结构化落账」的地基
|
||||||
(各项目 `.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)
|
### 📋 /ship 模板体检(第二轮分诊待行动 ×4)
|
||||||
- status: todo
|
- status: todo
|
||||||
|
|||||||
@ -28,14 +28,7 @@ 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],
|
||||||
|
|||||||
@ -80,11 +80,6 @@ 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 项目卡片上的蓝图状态徽章。",
|
||||||
@ -391,10 +386,6 @@ 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;
|
||||||
@ -1044,121 +1035,7 @@ 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())?;
|
||||||
// 双写到项目分片档案(可拆除性);失败不回滚笔记——DB 是事实源,backfill 可自愈
|
Ok("笔记已保存 ✅".to_string())
|
||||||
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.jsonl(JSONL 机器可重放重建 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> {
|
||||||
@ -1426,135 +1303,6 @@ 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]
|
#[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();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user