From 0851aa9783afc6e9ef2cb95ce76d8435bcf715b1 Mon Sep 17 00:00:00 2001 From: lanrtop Date: Sat, 3 Oct 2026 12:29:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(projects/parity):=20register=5Fproject=20?= =?UTF-8?q?=E6=8E=A7=E5=88=B6=E9=9D=A2=E6=8A=95=E5=BD=B1=EF=BC=8C=E7=99=BB?= =?UTF-8?q?=E8=AE=B0=E6=9C=AC=E4=BD=93=E6=8B=92=E7=BB=9D=E5=90=8C=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E9=87=8D=E5=A4=8D=E7=99=BB=E8=AE=B0=EF=BC=88P8f?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - projects:从 create_project 抽出登记本体 register_project_records(连接注入), UI create_project 与 MCP register_project 共用;只写数据库,不建目录、不执行 git init、不接入。 拒绝空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记、已存在的 ID; 重复检查与插入放进同一 IMMEDIATE 事务;workspace 插入去掉 OR IGNORE,ID 冲突不再静默成功 - 同目录判定 registration_path_key:去长路径前缀、斜杠统一、合并重复分隔符、去尾部分隔符、 不区分大小写;win_path 与 wsl_path 两列都比较,WSL Linux 路径按 wsl.localhost 换算; 错误信息给出已有 project_id(根治 PR #34 审查 L1/L2:同目录两条登记互相抢占 Codex 托管块) - mcp_tools:新增 register_project(path 必填、name 可选,project_id 由炼境生成 UUID,归入当前空间, structuredContent 返回);只开放在全局路由,is_project_scoped_tool 显式排除;描述写明须用户确认目标 - 批量添加对已登记目录计入 skipped,模板新建与挂载仓库复用同一重复目录检查 - project-context.yaml「MCP 工具接口」段同步(T1) cargo test 335 passed / 0 failed / 5 ignored(新增 10 条),0 warning;pnpm typecheck 通过; 9 处变异全部被对应用例捕获。 Feature-Confirmed: true Co-Authored-By: Claude Opus 5.5 --- docs/ai-context/project-context.yaml | 3 +- src-tauri/src/commands/git_repos.rs | 14 + src-tauri/src/commands/projects.rs | 533 ++++++++++++++++++++- src-tauri/src/commands/templates.rs | 6 + src-tauri/src/mcp_tools.rs | 104 +++- src/components/dashboard/BatchAddModal.tsx | 2 +- 6 files changed, 648 insertions(+), 14 deletions(-) diff --git a/docs/ai-context/project-context.yaml b/docs/ai-context/project-context.yaml index aa4b317..b166687 100644 --- a/docs/ai-context/project-context.yaml +++ b/docs/ai-context/project-context.yaml @@ -17,7 +17,8 @@ contracts: - "src-tauri/src/commands/gitea.rs" - "src-tauri/src/commands/dev_variables.rs" - "src-tauri/src/envoy.rs" - doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。" + - "src-tauri/src/commands/projects.rs" + doc: "工具 schema、dispatch 与调用说明必须同步;使者回传三件套:report_envoy_return 为项目级(三端在 scoped 地址可用;kind/confidence/suggestion/client 为枚举,scope 为 this_project|all_projects|project_type:<类型> 格式校验,content 压成单行且限长;无凭据字段,服务端对私钥头与常见令牌前缀做兜底拒绝,其余靠描述约束——内容会双写进子项目 .blueprint/notes-archive.jsonl 随项目进 Git;同项目同内容未处置时幂等返回原编号),resolve_envoy_return 与 list_envoy_returns 仅控制面(全局地址),子项目 agent 只回传不处置;list 默认只列未处置并报告已处置计数,include_resolved=true 附列回执,超出展示上限须明说不得静默截断,回传格式首行前缀【炼境使者回传】保持兼容(B4 沉默项目统计按前缀匹配);get_project_resource_status 为项目 scoped 只读状态,仅返回 Gitea PR/webhook 的状态与来源,不返回凭证、引用、URL;旧凭证未迁移时返回 pending_migration,不提前宣称 configured。MCP 不提供 reveal_api_key、get_secret 或任意 HTTP/SSH/SQL/部署代理。明文例外只限 read_project_dev_variable 的单项标量与 read_project_test_account 的单张非生产测试账户;均须匹配 scoped 项目及本机认证。register_project_test_account 只写非秘密需求元数据;列表不返回值,禁止批量导出。production delivery profile 由项目维护,炼境只读 Gitea 部署 context 与公开版本探针,双证据相符才能报告 deployed。get_delivery_status 的 PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;可选 pr_number(优先)或 branch 时改由 Gitea 解析 head 分支与 head SHA(指定分支无开放 PR 时取远端分支 head),CI 结论绑定该 SHA,PR 不存在返回 not_found;顶层 target 标明 mode(local_branch/pull_request/branch)、pr_number、branch、head_sha 与来源,git 字段始终反映主工作区;显式给出的非法 pr_number/branch 直接报错,不得静默回退主工作区。外部来源不可用时返回显式降级状态,不得伪造成功。verify_onboarding 必须同时返回静态接入 checks 与 configured/reachable/handshake/tools_ready/reload_required 运行态证据,配置存在不得替代运行可用。CI workflow 使用 plan_cicd_workflow 只读预览,用户确认相同 digest 后方可 apply_cicd_workflow 写入;该接口不接收凭证值且不触发 CI 或部署。unregister_project 与 UI 删除项目共用同一本体(projects::delete_project_registration):同一事务内解除 Gitea 仓库绑定,提交后尽力删除 Gitea 侧 webhook 与本机签名凭证;Gitea 轮询跳过所属项目已不存在的遗留绑定,且每个仓库写入前复核绑定仍在、项目仍登记,不向已删除或已解绑的项目 ID 写 commit 与 PR 事件;每个遗留绑定每次运行只记一次日志,日志写明清理路径(对该 ID 调 unregister_project)。register_project(P8f)只开放在全局路由(is_project_scoped_tool 显式排除,项目路由 tools/list 不含):与 UI create_project 共用登记本体 projects::register_project_records(连接注入),只写登记、不建目录、不执行 git init、不接入;拒绝空路径、相对路径、不存在的目录、已存在的 ID,以及同一目录的重复登记——目录按 projects::registration_path_key 字面规范化比较(去 Windows 长路径前缀、正反斜杠统一、合并重复分隔符、去尾部分隔符、不区分大小写;win_path 与 wsl_path 两列都算,WSL 的 Linux 路径按 wsl.localhost 的 UNC 路径换算),错误信息给出已有 project_id;MCP 入参只收 Windows 绝对路径 path 与可选 name,project_id 由炼境生成(UUID)并以 structuredContent 返回,归入当前空间;描述要求调用前向用户列出目标并确认。批量添加对同一目录跳过(计入 skipped),模板新建与挂载仓库复用同一重复目录检查。" - label: 被管理项目身份上下文 paths: diff --git a/src-tauri/src/commands/git_repos.rs b/src-tauri/src/commands/git_repos.rs index 2dd093c..844834e 100644 --- a/src-tauri/src/commands/git_repos.rs +++ b/src-tauri/src/commands/git_repos.rs @@ -155,6 +155,20 @@ pub fn mount_repo(input: MountRepoInput) -> Result<(), String> { let platform = input.platform.as_deref().unwrap_or("windows"); + // 同一目录已登记则拒绝(P8f,与登记本体同一检查) + if let Some((existing_id, existing_path)) = + crate::commands::projects::duplicate_registration_of( + &conn, + Some(platform), + input.win_path.as_deref(), + input.wsl_path.as_deref(), + )? + { + return Err(format!( + "该目录已登记为项目 project_id={existing_id}({existing_path}),拒绝重复挂载" + )); + } + // 创建 project_workspace(若同一 space 已挂载则报错) conn.execute( "INSERT INTO project_workspaces diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index 6d772a0..31d1606 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -43,7 +43,7 @@ const PROJECT_VIEW_SQL: &str = " LEFT JOIN git_repos gr ON gr.id = pw.repo_id "; -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] pub struct CreateProjectInput { pub id: String, pub name: String, @@ -179,9 +179,179 @@ pub fn get_project(id: String) -> Result { }) } -#[tauri::command] -pub fn create_project(input: CreateProjectInput) -> Result<(), String> { - let conn = pool().get().map_err(|e| e.to_string())?; +// ── 登记本体(P8f)───────────────────────────────────────────────────────────── + +/// 登记路径的比较键:去 `\\?\` 长路径前缀、`/` 统一为 `\`、合并重复分隔符(保留 UNC 开头的 `\\`)、 +/// 去首尾空白与尾部分隔符、不区分大小写。只做字面规范化,不解析 `..`、符号链接或 junction。 +pub(crate) fn registration_path_key(path: &str) -> String { + let unified = path.trim().replace('/', "\\"); + let unified = if let Some(rest) = unified.strip_prefix(r"\\?\UNC\") { + format!(r"\\{rest}") + } else if let Some(rest) = unified.strip_prefix(r"\\?\") { + rest.to_string() + } else { + unified + }; + let (prefix, rest) = match unified.strip_prefix(r"\\") { + Some(rest) => (r"\\", rest), + None => ("", unified.as_str()), + }; + let mut collapsed = String::with_capacity(rest.len()); + for ch in rest.chars() { + if ch == '\\' && collapsed.ends_with('\\') { + continue; + } + collapsed.push(ch); + } + format!("{prefix}{}", collapsed.trim_end_matches('\\')).to_lowercase() +} + +fn wsl_distro(conn: &rusqlite::Connection) -> String { + conn.query_row( + "SELECT value FROM settings WHERE key = 'wsl_distro'", + [], + |row| row.get(0), + ) + .unwrap_or_else(|_| "Ubuntu".to_string()) +} + +/// 一条登记指向的本机目录:Windows 项目取 win_path;WSL 项目取 wsl_path——Linux 路径按 +/// `\\wsl.localhost\\…` 换算(与 `mcp_inject::project_root_with_conn` 同口径), +/// 已是 Windows 路径(如 UI 选目录得到的 UNC)的原样使用。空路径与相对路径一律拒绝。 +fn registration_root( + platform: Option<&str>, + win_path: Option<&str>, + wsl_path: Option<&str>, + distro: &str, +) -> Result { + let is_wsl = platform == Some("wsl"); + let raw = if is_wsl { wsl_path } else { win_path } + .map(str::trim) + .unwrap_or_default(); + if raw.is_empty() { + return Err("项目路径为空:登记须给出已存在目录的绝对路径".to_string()); + } + if is_wsl && raw.starts_with('/') { + let inner = raw.trim_start_matches('/').replace('/', "\\"); + return Ok(std::path::PathBuf::from(format!( + r"\\wsl.localhost\{distro}\{inner}" + ))); + } + let path = std::path::PathBuf::from(raw); + if !path.is_absolute() { + return Err(format!("项目路径不是绝对路径:{raw:?}")); + } + Ok(path) +} + +/// 同一目录的已有登记(按 [`registration_path_key`] 比较,win_path 与 wsl_path 两列都算): +/// 返回其 project_id 与登记路径。 +pub(crate) fn find_registration_by_path( + conn: &rusqlite::Connection, + root: &std::path::Path, +) -> Result, String> { + let distro = wsl_distro(conn); + let key = registration_path_key(&root.to_string_lossy()); + let mut stmt = conn + .prepare("SELECT id, win_path, wsl_path FROM project_workspaces ORDER BY id") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + }) + .map_err(|e| e.to_string())?; + for row in rows { + let (id, win_path, wsl_path) = row.map_err(|e| e.to_string())?; + let candidates = [ + registration_root(None, win_path.as_deref(), None, &distro), + registration_root(Some("wsl"), None, wsl_path.as_deref(), &distro), + ]; + if let Some(existing) = candidates + .into_iter() + .flatten() + .find(|path| registration_path_key(&path.to_string_lossy()) == key) + { + return Ok(Some((id, existing.display().to_string()))); + } + } + Ok(None) +} + +/// 同一目录已登记时拒绝,错误里给出已有 project_id。同一目录两条登记会让 Codex 托管块等 +/// 按目录落盘的接入配置被两条登记互相抢占(PR #34 审查 L1/L2)。 +pub(crate) fn ensure_path_not_registered( + conn: &rusqlite::Connection, + root: &std::path::Path, +) -> Result<(), String> { + match find_registration_by_path(conn, root)? { + None => Ok(()), + Some((id, path)) => Err(format!( + "该目录已登记为项目 project_id={id}({path}),拒绝重复登记:同一目录两条登记会互相抢占 Codex 托管块等接入配置。请直接使用该 project_id;确需重新登记,先删除原登记" + )), + } +} + +/// 其他登记入口(批量添加、挂载仓库)复用的重复目录检查:路径缺省或无法解析时不在此拦截, +/// 沿用各入口原有行为;能解析出目录且已登记时返回已有登记。 +pub(crate) fn duplicate_registration_of( + conn: &rusqlite::Connection, + platform: Option<&str>, + win_path: Option<&str>, + wsl_path: Option<&str>, +) -> Result, String> { + match registration_root(platform, win_path, wsl_path, &wsl_distro(conn)) { + Ok(root) => find_registration_by_path(conn, &root), + Err(_) => Ok(None), + } +} + +/// 登记项目本体:UI `create_project` 与 MCP `register_project` 共用,连接由调用方注入。 +/// 只写数据库:不建目录、不执行 git init、不接入。 +/// 拒绝:空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记 +/// ([`ensure_path_not_registered`])、已存在的 ID。 +pub(crate) fn register_project_records( + conn: &rusqlite::Connection, + input: &CreateProjectInput, +) -> Result<(), String> { + if input.id.trim().is_empty() { + return Err("项目 ID 不能为空".to_string()); + } + let root = registration_root( + input.platform.as_deref(), + input.win_path.as_deref(), + input.wsl_path.as_deref(), + &wsl_distro(conn), + )?; + if !root.is_dir() { + return Err(format!( + "项目目录不存在或不是目录:{}(登记不建目录、不执行 git init)", + root.display() + )); + } + if input.name.trim().is_empty() { + return Err("项目名称不能为空".to_string()); + } + + // 先查后插放进 IMMEDIATE 事务:并发登记同一目录时第二个等到第一个提交后再查,不会各写一条 + let tx = rusqlite::Transaction::new_unchecked(conn, rusqlite::TransactionBehavior::Immediate) + .map_err(|e| e.to_string())?; + ensure_path_not_registered(&tx, &root)?; + let id_taken = tx + .query_row( + "SELECT 1 FROM project_workspaces WHERE id = ?1", + params![input.id], + |_| Ok(()), + ) + .optional() + .map_err(|e| e.to_string())? + .is_some(); + if id_taken { + return Err(format!("项目 ID 已存在:{}", input.id)); + } let profile_id = if let Some(ref pid) = input.profile_id { // 快速路径:profile 已存在,直接新建 workspace @@ -189,7 +359,7 @@ pub fn create_project(input: CreateProjectInput) -> Result<(), String> { } else { // 标准路径:先建 profile let pid = format!("prof-{}", input.id); - conn.execute( + tx.execute( "INSERT OR IGNORE INTO project_profiles (id, name, description, type, status, priority, tech_stack, repo_url, upstream_url, default_branch, @@ -215,8 +385,9 @@ pub fn create_project(input: CreateProjectInput) -> Result<(), String> { pid }; - conn.execute( - "INSERT OR IGNORE INTO project_workspaces + // 不用 OR IGNORE:冲突必须报错,不能静默返回成功 + tx.execute( + "INSERT INTO project_workspaces (id, profile_id, space_id, wsl_path, win_path, platform, recent_focus, startup_commands, updated_at) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,datetime('now'))", @@ -233,13 +404,71 @@ pub fn create_project(input: CreateProjectInput) -> Result<(), String> { ) .map_err(|e| { if e.to_string().contains("UNIQUE") { - "Project id already exists".into() + "该项目档案在当前空间已有登记".to_string() } else { e.to_string() } })?; + tx.commit().map_err(|e| e.to_string()) +} - Ok(()) +/// MCP `register_project` 的返回:新登记的 project_id 与目录。 +#[derive(Debug, Serialize)] +pub(crate) struct RegisteredProject { + pub project_id: String, + pub name: String, + pub path: String, + pub space_id: Option, +} + +/// MCP `register_project` 的入参换算(连接由调用方注入):只登记 Windows 目录;project_id 由炼境生成 +/// (UUID,与 UI 选目录新建一致);名称缺省取目录名;归入当前空间(UI 首页按当前空间列项目)。 +/// 校验全部交给 [`register_project_records`]。 +pub(crate) fn register_project_at( + conn: &rusqlite::Connection, + path: &str, + name: Option<&str>, +) -> Result { + let path = path.trim(); + let name = name + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + std::path::Path::new(path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.to_string()) + }); + let space_id: Option = conn + .query_row( + "SELECT id FROM spaces WHERE is_current = 1 ORDER BY created_at LIMIT 1", + [], + |row| row.get(0), + ) + .optional() + .map_err(|e| e.to_string())?; + let input = CreateProjectInput { + id: uuid::Uuid::new_v4().to_string(), + name, + space_id, + win_path: Some(path.to_string()), + platform: Some("windows".to_string()), + ..CreateProjectInput::default() + }; + register_project_records(conn, &input)?; + Ok(RegisteredProject { + project_id: input.id, + name: input.name, + path: path.to_string(), + space_id: input.space_id, + }) +} + +#[tauri::command] +pub fn create_project(input: CreateProjectInput) -> Result<(), String> { + let conn = pool().get().map_err(|e| e.to_string())?; + register_project_records(&conn, &input) } #[tauri::command] @@ -493,10 +722,17 @@ pub struct BatchProjectInput { pub platform: Option, } -/// 批量创建项目(已存在的 workspace id 跳过) +/// 批量创建项目(已存在的 workspace id 或已登记的同一目录跳过) #[tauri::command] pub fn batch_create_projects(items: Vec) -> Result, String> { let conn = pool().get().map_err(|e| e.to_string())?; + batch_register_projects(&conn, items) +} + +fn batch_register_projects( + conn: &rusqlite::Connection, + items: Vec, +) -> Result, String> { let mut skipped = Vec::new(); for item in items { let exists: bool = conn @@ -506,7 +742,14 @@ pub fn batch_create_projects(items: Vec) -> Result std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lj_p8f_{tag}_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn workspace_count(conn: &rusqlite::Connection) -> i64 { + conn.query_row("SELECT COUNT(*) FROM project_workspaces", [], |row| { + row.get(0) + }) + .unwrap() + } + + fn windows_input(id: &str, win_path: &str) -> CreateProjectInput { + CreateProjectInput { + id: id.to_string(), + name: id.to_string(), + win_path: Some(win_path.to_string()), + ..CreateProjectInput::default() + } + } + + #[test] + fn registration_path_key_folds_case_separators_and_trailing_slash() { + let key = registration_path_key(r"E:\Code-Project\My-App"); + for variant in [ + r"e:\code-project\my-app", + "E:/Code-Project/My-App", + r"E:\Code-Project\My-App\", + "E:/Code-Project/My-App/", + r"E:\\Code-Project\\\My-App", + r"\\?\E:\Code-Project\My-App", + " E:\\Code-Project\\My-App ", + ] { + assert_eq!(registration_path_key(variant), key, "{variant}"); + } + assert_ne!( + registration_path_key(r"E:\Code-Project\My-App2"), + key, + "不同目录不得被归并" + ); + assert_eq!( + registration_path_key("//wsl.localhost/Ubuntu/home/u/app/"), + registration_path_key(r"\\wsl.localhost\ubuntu\home\u\app"), + "UNC 保留开头的双分隔符" + ); + assert_eq!( + registration_path_key(r"\\?\UNC\server\share\app"), + registration_path_key(r"\\server\share\app") + ); + assert_eq!(registration_path_key(r"E:\"), registration_path_key("e:")); + } + + #[test] + fn registration_rejects_empty_relative_and_missing_paths_without_side_effects() { + let conn = crate::db::conn_with_schema(); + let missing = std::env::temp_dir().join(format!( + "lj_p8f_missing_{}_never_created", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&missing); + + let cases = [ + ("empty", String::new()), + ("blank", " ".to_string()), + ("relative", "src-tauri".to_string()), + // cargo test 的 cwd 是 crate 根,src 目录真实存在:只靠绝对路径校验拦下 + ("relative_existing", "src".to_string()), + ("drive_relative", "E:projects".to_string()), + ("missing", missing.display().to_string()), + ]; + for (label, path) in &cases { + let error = register_project_records(&conn, &windows_input(label, path)) + .expect_err(&format!("{label} 应被拒绝")); + assert!(!error.is_empty(), "{label}"); + } + assert_eq!(workspace_count(&conn), 0, "被拒绝的登记不得写入任何行"); + let profiles: i64 = conn + .query_row("SELECT COUNT(*) FROM project_profiles", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(profiles, 0, "被拒绝的登记不得留下 profile"); + assert!(!missing.exists(), "登记不建目录"); + + let wsl_empty = CreateProjectInput { + platform: Some("wsl".to_string()), + win_path: Some(r"E:\not-used".to_string()), + ..windows_input("wsl-empty", "") + }; + assert!( + register_project_records(&conn, &wsl_empty).is_err(), + "WSL 项目以 wsl_path 为准,为空即拒绝" + ); + } + + #[test] + fn registration_rejects_same_directory_in_any_spelling_and_names_existing_id() { + let conn = crate::db::conn_with_schema(); + let dir = temp_project_dir("dup"); + let canonical = dir.display().to_string(); + register_project_records(&conn, &windows_input("first", &canonical)).unwrap(); + assert!(!dir.join(".git").exists(), "登记不执行 git init"); + + for (index, variant) in [ + canonical.to_uppercase(), + canonical.to_lowercase(), + canonical.replace('\\', "/"), + format!("{canonical}\\"), + format!("{}/", canonical.replace('\\', "/")), + ] + .iter() + .enumerate() + { + let error = + register_project_records(&conn, &windows_input(&format!("dup-{index}"), variant)) + .expect_err(&format!("同一目录的写法 {variant} 应被拒绝")); + assert!( + error.contains("project_id=first"), + "错误里应给出已存在的 project_id: {error}" + ); + } + assert_eq!(workspace_count(&conn), 1, "重复登记不得写入第二行"); + + // UI 改走 WSL 平台登记同一目录(wsl_path 存 UNC 原样)同样拒绝 + let via_wsl = CreateProjectInput { + platform: Some("wsl".to_string()), + wsl_path: Some(canonical.to_uppercase()), + ..windows_input("dup-wsl", "") + }; + assert!(register_project_records(&conn, &via_wsl).is_err()); + assert_eq!(workspace_count(&conn), 1); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn duplicate_lookup_resolves_wsl_linux_paths_like_project_root() { + let conn = crate::db::conn_with_schema(); + conn.execute( + "INSERT INTO project_profiles (id, name) VALUES ('prof-w', 'w')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO project_workspaces (id, profile_id, wsl_path, platform) + VALUES ('wsl-app', 'prof-w', '/home/u/app', 'wsl')", + [], + ) + .unwrap(); + let found = find_registration_by_path( + &conn, + std::path::Path::new(r"\\wsl.localhost\UBUNTU\home\u\app\"), + ) + .unwrap(); + assert_eq!(found.map(|(id, _)| id).as_deref(), Some("wsl-app")); + let other = find_registration_by_path( + &conn, + std::path::Path::new(r"\\wsl.localhost\Ubuntu\home\u\app2"), + ) + .unwrap(); + assert!(other.is_none()); + } + + #[test] + fn registration_rejects_taken_id_instead_of_silently_ignoring() { + let conn = crate::db::conn_with_schema(); + let first = temp_project_dir("id_a"); + let second = temp_project_dir("id_b"); + register_project_records( + &conn, + &windows_input("same-id", &first.display().to_string()), + ) + .unwrap(); + let error = register_project_records( + &conn, + &windows_input("same-id", &second.display().to_string()), + ) + .expect_err("ID 已存在应报错,不能静默返回成功"); + assert!(error.contains("same-id"), "{error}"); + let stored: String = conn + .query_row( + "SELECT win_path FROM project_workspaces WHERE id = 'same-id'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(stored, first.display().to_string(), "原登记不被改写"); + std::fs::remove_dir_all(&first).unwrap(); + std::fs::remove_dir_all(&second).unwrap(); + } + + #[test] + fn register_project_at_generates_id_defaults_name_and_joins_current_space() { + let conn = crate::db::conn_with_schema(); + conn.execute( + "INSERT INTO spaces (id, name, local_root, is_current) VALUES ('home', 'home', '', 1)", + [], + ) + .unwrap(); + let dir = temp_project_dir("mcp"); + let registered = + register_project_at(&conn, &format!(" {} ", dir.display()), None).unwrap(); + + assert!(uuid::Uuid::parse_str(®istered.project_id).is_ok()); + assert_eq!( + registered.name, + dir.file_name().unwrap().to_string_lossy(), + "名称缺省取目录名" + ); + assert_eq!(registered.space_id.as_deref(), Some("home")); + let (win_path, platform, space): (String, String, Option) = conn + .query_row( + "SELECT win_path, platform, space_id FROM project_workspaces WHERE id = ?1", + params![registered.project_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .unwrap(); + assert_eq!( + win_path, + dir.display().to_string(), + "存入去掉首尾空白的路径" + ); + assert_eq!(platform, "windows"); + assert_eq!(space.as_deref(), Some("home")); + + let again = register_project_at(&conn, &dir.display().to_string(), Some("别名")) + .expect_err("同一目录再次登记应被拒绝"); + assert!(again.contains(®istered.project_id), "{again}"); + assert!(register_project_at(&conn, "", None).is_err()); + assert_eq!(workspace_count(&conn), 1); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn batch_registration_skips_directories_already_registered() { + let conn = crate::db::conn_with_schema(); + let dir = temp_project_dir("batch"); + let path = dir.display().to_string(); + register_project_records(&conn, &windows_input("existing", &path)).unwrap(); + let item = |id: &str, win_path: String| BatchProjectInput { + id: id.to_string(), + name: id.to_string(), + win_path: Some(win_path), + wsl_path: None, + status: "active".to_string(), + priority: "mid".to_string(), + space_id: None, + platform: Some("windows".to_string()), + }; + let fresh = r"Z:\lj-p8f-batch-fresh".to_string(); + let skipped = batch_register_projects( + &conn, + vec![ + item("dup", path.to_uppercase()), + item("fresh", fresh.clone()), + item("fresh-again", format!("{}\\", fresh.to_lowercase())), + ], + ) + .unwrap(); + assert_eq!(skipped, vec!["dup".to_string(), "fresh-again".to_string()]); + assert_eq!(workspace_count(&conn), 2); + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/src-tauri/src/commands/templates.rs b/src-tauri/src/commands/templates.rs index c82cba8..58c779a 100644 --- a/src-tauri/src/commands/templates.rs +++ b/src-tauri/src/commands/templates.rs @@ -227,6 +227,12 @@ pub fn create_project_from_template( if project_root.exists() { return Err(format!("目录已存在: {}", project_win_path)); } + // 模板新建会先建目录,过不了登记本体的「目录须已存在」校验,只复用其重复目录检查(P8f): + // 目录已删但登记仍在时,不能再新建出第二条同目录登记 + { + let conn = db::pool().get().map_err(|e| e.to_string())?; + crate::commands::projects::ensure_path_not_registered(&conn, project_root)?; + } // ── 2. 创建根目录 ───────────────────────────────────────────────────────── let _ = app.emit("template:progress", "__step:1"); diff --git a/src-tauri/src/mcp_tools.rs b/src-tauri/src/mcp_tools.rs index c9df25d..c988f5c 100644 --- a/src-tauri/src/mcp_tools.rs +++ b/src-tauri/src/mcp_tools.rs @@ -400,11 +400,32 @@ pub fn tools_list_result() -> Value { if let Value::Array(envoy_tools) = envoy_tool_schemas() { tools.splice(after_list..after_list, envoy_tools); } + let after_scan = tools + .iter() + .position(|tool| tool["name"] == "scan_unregistered_projects") + .map_or(tools.len(), |index| index + 1); + tools.insert(after_scan, register_project_schema()); } apply_tool_annotations(&mut result); result } +/// 登记项目(P8f,单独成函数:并入主清单会超出 json! 宏递归上限)。只开放在全局地址。 +fn register_project_schema() -> Value { + json!({ + "name": "register_project", + "description": "把本机一个已存在的目录登记为炼境项目(等价 UI「添加项目」的 agent 投影,只开放在全局地址)。只写炼境登记:不建目录、不执行 git init、不接入任何客户端。空路径、相对路径、不存在的目录一律拒绝;同一目录(大小写、斜杠方向、尾部分隔符归一后比较)已登记时拒绝并在错误里给出已有 project_id——直接复用它,不要换写法重试。project_id 由炼境生成并返回。这是写操作:调用前先向用户列出目标目录与名称并取得明确确认。登记后的接入先用 plan_onboarding 预览,经用户确认再 onboard_project。适用:scan_unregistered_projects 发现的漏登记目录、用户新建的项目目录。", + "inputSchema": { + "type": "object", + "properties": { + "path": { "type": "string", "description": "项目目录的 Windows 绝对路径,目录须已存在(如 E:\\code-project\\my-app)" }, + "name": { "type": "string", "description": "可选。项目显示名;省略则取目录名" } + }, + "required": ["path"] + } + }) +} + /// 使者回传两件工具(单独成函数:并入主清单会超出 json! 宏递归上限) fn envoy_tool_schemas() -> Value { json!([ @@ -447,7 +468,11 @@ fn is_project_scoped_tool(tool: &Value) -> bool { if name == "get_diagnostics" { return true; } - if matches!(name, "unregister_project" | "setup_gitea_repo") { + // 登记与删除登记属于控制面,项目路由一律不开放(即使日后 schema 带上 project_id) + if matches!( + name, + "register_project" | "unregister_project" | "setup_gitea_repo" + ) { return false; } tool.get("inputSchema") @@ -738,6 +763,12 @@ fn structured_tool_summary(tool_name: &str, value: &Value) -> String { .as_str() .unwrap_or("接入验收结构化结果已返回") .to_string(), + "register_project" => format!( + "已登记项目:project_id={},名称={},目录={}。只写了登记:未建目录、未执行 git init、尚未接入;接入先 plan_onboarding 预览,经用户确认再 onboard_project", + value["project_id"].as_str().unwrap_or("?"), + value["name"].as_str().unwrap_or("?"), + value["path"].as_str().unwrap_or("?") + ), _ => "结构化结果已返回".to_string(), } } @@ -748,6 +779,7 @@ fn tool_success_response(tool_name: &str, text: String) -> Value { "get_toolchain_status", "get_project_resource_status", "verify_onboarding", + "register_project", ]; if STRUCTURED_TOOLS.contains(&tool_name) { if let Ok(structured) = serde_json::from_str::(&text) { @@ -1208,6 +1240,13 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option list_all_projects(), "scan_unregistered_projects" => scan_unregistered_projects(), + "register_project" => { + let path = args.get("path").and_then(Value::as_str).unwrap_or(""); + let name = args.get("name").and_then(Value::as_str); + let conn = db::pool().get().map_err(|e| e.to_string())?; + let registered = crate::commands::projects::register_project_at(&conn, path, name)?; + serde_json::to_string_pretty(®istered).map_err(|e| e.to_string()) + } "unregister_project" => { let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or(""); if pid.is_empty() { @@ -2529,6 +2568,69 @@ mod tests { assert_eq!(global_write["structuredContent"]["code"], "scope_violation"); } + #[test] + fn register_project_is_global_only_write_tool_requiring_user_confirmation() { + let global = tools_list_result(); + let tool = global["tools"] + .as_array() + .unwrap() + .iter() + .find(|tool| tool["name"] == "register_project") + .expect("全局路由应提供 register_project") + .clone(); + assert_eq!(tool["inputSchema"]["required"], json!(["path"])); + assert_eq!(tool["annotations"]["readOnlyHint"], false); + assert_eq!(tool["annotations"]["destructiveHint"], false); + let description = tool["description"].as_str().unwrap(); + for phrase in ["用户", "确认", "不建目录", "git init", "project_id"] { + assert!(description.contains(phrase), "描述应写明「{phrase}」"); + } + + let scoped = tools_list_result_for_project("p1"); + assert!( + !scoped["tools"] + .as_array() + .unwrap() + .iter() + .any(|tool| tool["name"] == "register_project"), + "项目路由的 tools/list 不得含 register_project" + ); + // 日后 schema 若带上 project_id,也不能因此漏进项目路由 + let mut with_project_id = tool; + with_project_id["inputSchema"]["properties"]["project_id"] = json!({"type": "string"}); + assert!(!is_project_scoped_tool(&with_project_id)); + } + + #[tokio::test] + async fn register_project_over_project_route_is_scope_violation_before_io() { + let response = tools_call_for_project( + Some(&json!({ + "name": "register_project", + "arguments": { "path": "E:\\somewhere" } + })), + "p1", + ) + .await; + assert_eq!(response["isError"], true); + assert_eq!(response["structuredContent"]["code"], "scope_violation"); + } + + #[test] + fn register_project_response_carries_structured_project_id() { + let text = json!({ + "project_id": "0b6c", + "name": "my-app", + "path": "E:\\code\\my-app", + "space_id": null + }) + .to_string(); + let response = tool_success_response("register_project", text); + assert_eq!(response["structuredContent"]["project_id"], "0b6c"); + let summary = response["content"][0]["text"].as_str().unwrap(); + assert!(summary.contains("project_id=0b6c"), "{summary}"); + assert!(summary.contains("尚未接入"), "{summary}"); + } + #[test] fn envoy_tools_split_between_project_scope_and_control_plane() { let scoped = tools_list_result_for_project("p1"); diff --git a/src/components/dashboard/BatchAddModal.tsx b/src/components/dashboard/BatchAddModal.tsx index ad27997..266641e 100644 --- a/src/components/dashboard/BatchAddModal.tsx +++ b/src/components/dashboard/BatchAddModal.tsx @@ -99,7 +99,7 @@ export function BatchAddModal({ onClose }: { onClose: () => void }) { qc.invalidateQueries({ queryKey: ["projects"] }); if (skipped.length > 0) { - showToast(`已添加 ${selected.length - skipped.length} 个,跳过 ${skipped.length} 个(ID 已存在)`); + showToast(`已添加 ${selected.length - skipped.length} 个,跳过 ${skipped.length} 个(ID 已存在或目录已登记)`); } else { showToast(`成功添加 ${selected.length} 个项目 ✓`); }