@ -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.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 ) = ( 0 usize , 0 usize ) ;
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 档案测试用最小 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 ( ) ;