diff --git a/.blueprint/manifest.yaml b/.blueprint/manifest.yaml index e58f377..59d2ca2 100644 --- a/.blueprint/manifest.yaml +++ b/.blueprint/manifest.yaml @@ -511,9 +511,9 @@ modules: name: 三端客户端对等 area: infra status: in_progress - progress: 35 + progress: 39 updated: 2026-10-03 - note: "P1/P2/P3/P3b/P7/P9/P10 完成:三端(Claude Code、Codex 含 Desktop、DSH 含桌面 GUI)在 Enterprise 经 %USERPROFILE%\\.lian-jing 稳定入口连项目专用地址,人终验通过。Claude 条目经 claude CLI 写入、以 claude mcp get 为准;DSH 由托管块全局行(headless)+ 会话级插件(GUI/web/acp 按会话工作目录)路由;接入写入幂等;接入包改写块外带未提交改动的托管块文件时给出混合文件预警;P7 实测 MCP 服务器说明仅 Claude/DSH 带入模型上下文、Codex 不带,引导提示保留在 skill。2026-10-03 P8 一键接入设计定稿(docs/tasks/2026-10-03-p8-onboarding-oneclick-design.md):A1 不前置;P4 分 L0 静态 / L1 零模型 / L3 无头会话,P8 只依赖 L1(P4a);拆为 P8-0、P8a–P8g 与 P4-0、P4a–P4e(共 13 张待做 S/M 卡),P8 进行中;进度按拆分后卡数重算。待做 P5、P6(基于 P8c 的 WriteSink)。" + note: "P1/P2/P3/P3b/P7/P9/P10 完成:三端(Claude Code、Codex 含 Desktop、DSH 含桌面 GUI)在 Enterprise 经 %USERPROFILE%\\.lian-jing 稳定入口连项目专用地址,人终验通过。Claude 条目经 claude CLI 写入、以 claude mcp get 为准;DSH 由托管块全局行(headless)+ 会话级插件(GUI/web/acp 按会话工作目录)路由;接入写入幂等;接入包改写块外带未提交改动的托管块文件时给出混合文件预警;P7 实测 MCP 服务器说明仅 Claude/DSH 带入模型上下文、Codex 不带,引导提示保留在 skill。2026-10-03 P8 一键接入设计定稿(docs/tasks/2026-10-03-p8-onboarding-oneclick-design.md):A1 不前置;P4 分 L0 静态 / L1 零模型 / L3 无头会话,P8 只依赖 L1(P4a);拆为 P8-0、P8a–P8g 与 P4-0、P4a–P4e(P8-0、P8a 已完成;P8a 把「已接入」统一为有项目 token),P8 进行中;进度按拆分后卡数重算。待做 P5、P6(基于 P8c 的 WriteSink)。" position: [3500, 950] - id: architecture-evolution diff --git a/.blueprint/modules/agent-client-parity.md b/.blueprint/modules/agent-client-parity.md index d3d2537..0c34f06 100644 --- a/.blueprint/modules/agent-client-parity.md +++ b/.blueprint/modules/agent-client-parity.md @@ -215,13 +215,15 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。 - 依赖顺序:P8-0 → P8a → P8b → P8c → P4a(另依赖 P4-0)→ P8d → P8e → P8g;P8f 可选、独立,P8e 端到端验收前完成。P8 关键路径不依赖 L3。 - verification: "2026-10-03:manifest.yaml 经 PyYAML safe_load 解析通过(agent-client-parity progress 35、edges 95 条);manifest 全文「P4/P8 依赖」「依赖 architecture-evolution A1」「A1 的 CLI 投影」0 处;设计文档入库 docs/tasks/2026-10-03-p8-onboarding-oneclick-design.md,§9 十一项推荐项已勾选。" -### 📋 P8a — 「已接入」单一判定 -- status: todo +### ✅ P8a — 「已接入」单一判定 +- status: done - complexity: S - files: src-tauri/src/mcp_inject.rs, src-tauri/src/mcp_tools.rs - depends: P8-0 - acceptance: "agent_verify: [cargo test mcp_inject] in-memory DB + 假 CredentialStore:有 token、不在任何组的项目进入 reinject_all_mcp 的目标集合;组成员仍在;token 已删的项目不在(锁内复查语义不变);新函数的连接由调用方传入。[cargo test mcp_tools] list_all_projects 的「已注入」与 DSH 白名单、根目录迁移调用同一个函数:删除 .claude/liangjing.json 不改变判定,删除 token 后判为未注入。" - 起因:「已接入」现有三套判定——reinject_all_mcp 按组成员、DSH 白名单与根目录迁移按 token、list_all_projects 按 .claude/liangjing.json 是否存在;ensure_standalone_group 只在项目不在任何组时才注入,已在组内的项目重跑接入包不会修复本机注册。 +- 实现(2026-10-03):mcp_inject 新增 is_project_injected(store, project_id)(有项目 MCP token 即已接入),DSH 白名单、on_project_root_changed、list_all_projects 改为调用它;reinject_all_mcp 的目标由「组成员」扩为「组成员 ∪ 有 token 的登记项目」(reinject_targets),锁内复查改为 is_reinject_target(仍是组成员或仍有 token),连接与凭据存储由调用方注入。list_all_projects 拆出 render_all_projects(conn, store),顺带去掉循环内的二次取池。行为变化:独立接入(不在任何组)的项目在端口变更后也会被重注入;list_all_projects 对只剩兼容文件、token 已删的项目改报「未注入」。ensure_standalone_group 的「已在组内不注入」由 P8d 编排本体解决,本卡不动。 +- verification: "2026-10-03:cargo test 286 passed / 0 failed / 5 ignored;新增 reinject_targets_follow_single_injected_judgment(组成员无 token 仍是目标、独立有 token 进入目标、删 token 后锁内复查为否且移出目标)与 list_all_projects_judges_injection_by_token_not_compat_file(只有 liangjing.json 无 token → 未注入;有 token 删兼容文件 → 已注入;删 token → 未注入)。变异:屏蔽 reinject_targets 的 token 分支、list_all_projects 改回看 liangjing.json,两条新测试均失败。契约同步 docs/ai-context/project-context.yaml「被管理项目身份上下文」。" ### 📋 P8b — 客户端适配器注册表 + 结构化注入结果 + A1a 适应度测试 - status: todo diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index 947bdad..a33798d 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -26,7 +26,7 @@ contracts: - ".lian-jing/context.json" - ".lian-jing/capability-packs.json" - ".claude/liangjing.json" - doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较只忽略 CRLF/LF 与文末托管块连同其前空白——即炼境追加托管块带来的差异,块外空行增删与行尾空格均算改动),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。模板分发之后的步骤(lefthook 激活、buff)报错时,已改写的混合文件名并入错误信息返回。" + doc: "`.lian-jing/context.json` 是 project_id 的工具无关单一事实来源;`.claude/liangjing.json` 仅为兼容投影;`.lian-jing/capability-packs.json` 是炼境分发基线的版本描述。lian-jing skill 以同一模板双投影:`.agents/skills/lian-jing/`(Codex、DSH 读取)与 `.claude/skills/lian-jing/`(Claude Code 不读 .agents/skills),两份内容必须一致。context.json 可选字段 mcp_scope=global 声明控制面项目(如炼境自身):Claude 不写 local 条目、DSH 不入白名单,保留全局地址与跨项目工具。迁移必须保留未知字段;验证必须返回显式状态与 upgrade_hint;升级仅覆盖炼境托管文件,绝不覆盖项目定制或手工 MCP 配置,且不得以客户端全局配置或项目 UI 身份替代项目根目录上下文。apply_onboarding_pack 的模板结果 TemplateResult 可带 warning(混合文件预警):写入前 AGENTS.md、docs/ai-context/git-workflow.md 或 CLAUDE.md 在 git 中已有托管块(ONBOARDING_MANAGED / BLUEPRINT_MANAGED)之外的未提交改动(未跟踪文件带块外内容也算;比较只忽略 CRLF/LF 与文末托管块连同其前空白——即炼境追加托管块带来的差异,块外空行增删与行尾空格均算改动),且本次确实改写了该文件时给出;只提示不阻断、块外内容不动,非 git 仓库或 git 不可用时静默。CLAUDE.md 只由蓝图规则同步改写,仅在预警时补一条结果。模板分发之后的步骤(lefthook 激活、buff)报错时,已改写的混合文件名并入错误信息返回。「已接入」只有一个判定:本机受保护存储里有项目 MCP token(mcp_inject::is_project_injected);DSH 白名单、根目录迁移、批量重注入(产品组成员 + 有 token 的独立项目)与 list_all_projects 同源,.claude/liangjing.json 不作判据。" - label: Tauri command 接口(前后端契约) paths: diff --git a/src-tauri/src/mcp_inject.rs b/src-tauri/src/mcp_inject.rs index a53b214..cde9a60 100644 --- a/src-tauri/src/mcp_inject.rs +++ b/src-tauri/src/mcp_inject.rs @@ -803,12 +803,7 @@ fn dsh_project_roots( let value = if context.get("mcp_scope").and_then(Value::as_str) == Some("global") { None } else { - let has_token = crate::credential_store::mcp_auth_digest(&id) - .and_then(|digest| { - crate::credential_store::read_mcp_auth_token_for_digest(store, &digest) - }) - .is_ok(); - if !has_token { + if !is_project_injected(store, &id) { return None; } Some(id) @@ -1412,45 +1407,86 @@ pub fn migrate_to_single_server() { } } -/// 端口变更后重新注入所有产品组成员的 MCP 配置 +/// 「已接入」的单一判定:本机受保护存储里有该项目的 MCP 认证 token。 +/// token 由注入创建、由 remove_project_mcp 最先删除;DSH 白名单、根目录迁移、批量重注入与 +/// list_all_projects 都以此为准(P8a)。项目里的 .claude/liangjing.json 只是兼容投影,可能残留或缺失,不作判据。 +pub(crate) fn is_project_injected( + store: &impl crate::credential_store::CredentialStore, + project_id: &str, +) -> bool { + crate::credential_store::mcp_auth_digest(project_id) + .and_then(|digest| crate::credential_store::read_mcp_auth_token_for_digest(store, &digest)) + .is_ok() +} + +/// 批量重注入的目标:产品组成员(历史上入组即注入),加上已接入(有 token)但不在任何组的登记项目。 +/// 后者此前被漏掉——独立接入的项目在端口变更后不会被重注入,项目配置仍指向旧端口。 +fn reinject_targets( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, +) -> Result, String> { + let query_ids = |sql: &str| -> Result, String> { + let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?; + let ids = stmt + .query_map([], |row| row.get(0)) + .map_err(|e| e.to_string())? + .filter_map(Result::ok) + .collect(); + Ok(ids) + }; + let mut targets: std::collections::BTreeSet = + query_ids("SELECT DISTINCT project_id FROM project_group_map")? + .into_iter() + .collect(); + for id in query_ids("SELECT id FROM project_workspaces")? { + if is_project_injected(store, &id) { + targets.insert(id); + } + } + Ok(targets.into_iter().collect()) +} + +/// 锁内复查:目标快照取自锁外,期间被移出组且 token 已删的项目不再注入(避免重建刚删除的 token)。 +fn is_reinject_target( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, + project_id: &str, +) -> Result { + let member = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM project_group_map WHERE project_id = ?1)", + [project_id], + |row| row.get::<_, bool>(0), + ) + .map_err(|error| error.to_string())?; + Ok(member || is_project_injected(store, project_id)) +} + +/// 端口变更后重新注入所有已接入项目(产品组成员 + 有 token 的独立项目)的 MCP 配置 /// 返回注入失败的错误列表(部分失败不影响其他项目) pub fn reinject_all_mcp() -> Vec { - let conn = match db::pool().get() { - Ok(c) => c, - Err(e) => return vec![e.to_string()], + let store = crate::credential_store::WindowsCredentialStore; + let project_ids = match db::pool() + .get() + .map_err(|e| e.to_string()) + .and_then(|conn| reinject_targets(&conn, &store)) + { + Ok(ids) => ids, + Err(e) => return vec![e], }; - let project_ids: Vec = { - let mut stmt = match conn.prepare("SELECT DISTINCT project_id FROM project_group_map") { - Ok(s) => s, - Err(e) => return vec![e.to_string()], - }; - stmt.query_map([], |row| row.get(0)) - .map(|iter| iter.filter_map(|r| r.ok()).collect()) - .unwrap_or_default() - }; - drop(conn); - let mut errors = Vec::new(); for project_id in project_ids { let _guard = mcp_op_lock(); - // 快照取自锁外:锁内复查成员关系,已被移出组的项目不再重新注入(避免重建刚删除的 token) - let still_member = db::pool() + let still_target = db::pool() .get() .map_err(|error| error.to_string()) - .and_then(|conn| { - conn.query_row( - "SELECT EXISTS(SELECT 1 FROM project_group_map WHERE project_id = ?1)", - [&project_id], - |row| row.get::<_, bool>(0), - ) - .map_err(|error| error.to_string()) - }); - match still_member { + .and_then(|conn| is_reinject_target(&conn, &store, &project_id)); + match still_target { Ok(true) => {} Ok(false) => continue, Err(error) => { - errors.push(format!("项目 {project_id}: 复查组成员关系失败: {error}")); + errors.push(format!("项目 {project_id}: 复查接入状态失败: {error}")); continue; } } @@ -1473,15 +1509,7 @@ pub fn on_project_root_changed(project_id: &str, old_root: Option) { if let Some(root) = old_root { sync_claude_local(project_id, &root, None); } - let injected = crate::credential_store::mcp_auth_digest(project_id) - .and_then(|digest| { - crate::credential_store::read_mcp_auth_token_for_digest( - &crate::credential_store::WindowsCredentialStore, - &digest, - ) - }) - .is_ok(); - if !injected { + if !is_project_injected(&crate::credential_store::WindowsCredentialStore, project_id) { refresh_dsh_patch(None); return; } @@ -1717,6 +1745,51 @@ mod tests { ); } + #[test] + fn reinject_targets_follow_single_injected_judgment() { + let conn = crate::db::conn_with_schema(); + let store = crate::credential_store::MemoryCredentialStore::new(); + conn.execute( + "INSERT INTO product_groups (id, name) VALUES ('g1', 'G')", + [], + ) + .unwrap(); + for id in ["grouped", "standalone", "removed"] { + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES (?1, 'QA')", + [format!("{id}-profile")], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id, win_path) VALUES (?1, ?2, ?3)", + rusqlite::params![id, format!("{id}-profile"), format!("C:\\qa\\{id}")], + ) + .unwrap(); + } + conn.execute( + "INSERT INTO project_group_map (project_id, group_id) VALUES ('grouped', 'g1')", + [], + ) + .unwrap(); + // standalone:独立接入(有 token、不在任何组),此前被批量重注入漏掉;removed:从未接入或已移除 + crate::credential_store::get_or_create_mcp_auth_token(&store, "standalone").unwrap(); + assert!(is_project_injected(&store, "standalone")); + assert!(!is_project_injected(&store, "removed")); + assert_eq!( + reinject_targets(&conn, &store).unwrap(), + vec!["grouped".to_string(), "standalone".to_string()] + ); + // 锁内复查:组成员仍是目标;独立项目的 token 先被 remove_project_mcp 删掉后不再注入 + assert!(is_reinject_target(&conn, &store, "grouped").unwrap()); + assert!(is_reinject_target(&conn, &store, "standalone").unwrap()); + crate::credential_store::delete_mcp_auth_token_for_project(&store, "standalone").unwrap(); + assert!(!is_reinject_target(&conn, &store, "standalone").unwrap()); + assert_eq!( + reinject_targets(&conn, &store).unwrap(), + vec!["grouped".to_string()] + ); + } + #[test] fn global_scope_projects_are_excluded_and_roots_follow_registry() { let base = std::env::temp_dir().join(format!("lianjing_dsh_roots_{}", std::process::id())); diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index 853e2ae..73cd95d 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -1997,6 +1997,22 @@ fn get_project_snapshots(project_id: &str, days: u32) -> Result pub fn list_all_projects() -> Result { let conn = db::pool().get().map_err(|e| e.to_string())?; + render_all_projects(&conn, &crate::credential_store::WindowsCredentialStore) +} + +/// 「已注入」按 mcp_inject::is_project_injected(有项目 token)判定,与 DSH 白名单、根目录迁移、批量重注入同源; +/// 不再看项目里的 .claude/liangjing.json(兼容投影,可能残留或缺失)。 +fn render_all_projects( + conn: &rusqlite::Connection, + store: &impl crate::credential_store::CredentialStore, +) -> Result { + let wsl_distro = conn + .query_row( + "SELECT value FROM settings WHERE key = 'wsl_distro'", + [], + |r| r.get::<_, String>(0), + ) + .unwrap_or_else(|_| "Ubuntu".to_string()); let mut stmt = conn .prepare( "SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, pgm.group_id @@ -2037,35 +2053,14 @@ pub fn list_all_projects() -> Result { for (id, name, platform, win_path, wsl_path, group_id) in &rows { let project_path = if platform.as_deref() == Some("wsl") { wsl_path.as_ref().map(|p| { - let conn2 = db::pool().get().ok(); - let distro = conn2 - .and_then(|c| { - c.query_row( - "SELECT value FROM settings WHERE key = 'wsl_distro'", - [], - |r| r.get::<_, String>(0), - ) - .ok() - }) - .unwrap_or_else(|| "Ubuntu".to_string()); let inner = p.trim_start_matches('/').replace('/', "\\"); - format!("\\\\wsl.localhost\\{}\\{}", distro, inner) + format!("\\\\wsl.localhost\\{}\\{}", wsl_distro, inner) }) } else { win_path.clone() }; - let injected = project_path - .as_ref() - .map(|p| { - std::path::Path::new(p) - .join(".claude") - .join("liangjing.json") - .exists() - }) - .unwrap_or(false); - - let inject_icon = if injected { + let inject_icon = if crate::mcp_inject::is_project_injected(store, id) { "✅ 已注入" } else { "⚠️ 未注入" @@ -2203,6 +2198,44 @@ pub fn scan_unregistered_projects() -> Result { mod tests { use super::*; + #[test] + fn list_all_projects_judges_injection_by_token_not_compat_file() { + let conn = crate::db::conn_with_schema(); + let store = crate::credential_store::MemoryCredentialStore::new(); + let root = std::env::temp_dir().join(format!("lj_list_inject_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join(".claude")).unwrap(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES ('p-profile', 'QA')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id, win_path) VALUES ('p', 'p-profile', ?1)", + [root.display().to_string()], + ) + .unwrap(); + let injected = |out: &str| { + assert_ne!( + out.contains("✅ 已注入"), + out.contains("⚠️ 未注入"), + "单个项目只能有一种状态" + ); + out.contains("✅ 已注入") + }; + // 只有兼容投影 .claude/liangjing.json、没有 token:未注入 + std::fs::write(root.join(".claude/liangjing.json"), "{}").unwrap(); + assert!(!injected(&render_all_projects(&conn, &store).unwrap())); + // 有 token、删掉兼容文件:已注入(与 DSH 白名单、根目录迁移同一判定) + crate::credential_store::get_or_create_mcp_auth_token(&store, "p").unwrap(); + std::fs::remove_file(root.join(".claude/liangjing.json")).unwrap(); + assert!(injected(&render_all_projects(&conn, &store).unwrap())); + // 删除 token:未注入 + crate::credential_store::delete_mcp_auth_token_for_project(&store, "p").unwrap(); + assert!(!injected(&render_all_projects(&conn, &store).unwrap())); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn tools_list_contains_expected_tools() { let result = tools_list_result();