Compare commits

...

3 Commits

Author SHA1 Message Date
lanrtop
bd4ebaf096 docs(blueprint/agent-client-parity): P8f 记实现、规范化规则与验证,链路实测待 P8e 合并后由统筹会话完成(P8f)
All checks were successful
Push & PR Check / check (push) Successful in 1m8s
卡片保持 🔵:acceptance 的 [MCP 全局] register_project → plan_onboarding → onboard_project
依赖 P8e 才有的两个 MCP 工具。另记行为变化(UI 添加项目改为拒绝空路径、相对或不存在的路径、
ID 冲突)、其他登记入口的取舍、没验什么、known_limits 与有意偏差。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:29:49 +09:00
lanrtop
0851aa9783 feat(projects/parity): register_project 控制面投影,登记本体拒绝同目录重复登记(P8f)
- 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 <noreply@anthropic.com>
2026-10-03 12:29:26 +09:00
lanrtop
cf0f5b6a64 docs(blueprint/agent-client-parity): P8f 开工标 🔵(P8f)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 12:11:14 +09:00
7 changed files with 660 additions and 17 deletions

View File

@ -284,13 +284,22 @@ skill 只是五个接入面之一,兼容层覆盖全部五面。
- acceptance: "agent_verify: [cargo test mcp_tools] 全局和项目路由的 tools/list 都含 plan_onboarding 与 onboard_project;项目路由上强制本项目,传跨项目参数返回 scope_violation;onboard_project 的 required 含 expected_digest 和 confirmed;annotations 中 plan 为 readOnly、apply 为 idempotent。[pnpm typecheck] 通过。[MCP 全局] 对 enterprise 做 plan 全部 noop,做 apply 结果 done 且各步都是 converged。[读文件] project-context.yaml 已补 plan、digest 与状态语义(T1)。"
- 说明:两个工具的 schema 单独成函数(避开 json! 递归上限),dispatch 里只留一行委托,减少与 A4(rmcp)迁移的冲突面;AGENTS.md 架构速查补 src-tauri/src/onboard/。
### 📋 P8f — register_project 控制面投影
- status: todo
### 🔵 P8f — register_project 控制面投影
- status: in_progress
- complexity: S
- files: src-tauri/src/commands/projects.rs, src-tauri/src/mcp_tools.rs
- files: src-tauri/src/commands/projects.rs, src-tauri/src/mcp_tools.rs, src-tauri/src/commands/templates.rs(实施扩展), src-tauri/src/commands/git_repos.rs(实施扩展), src/components/dashboard/BatchAddModal.tsx(实施扩展), docs/ai-context/project-context.yaml(T1)
- depends: P8-0
- acceptance: "agent_verify: [cargo test] 空路径、相对路径、不存在的路径、重复登记都被拒绝,不建目录、不执行 git init。[MCP 全局] 对临时 git 仓库依次调用 register_project、plan_onboarding、onboard_project,结果为 done;项目路由的 tools/list 不含 register_project。"
- 为什么(Q7):create_project 只写 DB、MCP 里也没有登记项目的工具,agent 复刻不了人「新建 → 接入」的操作路径(违反同源同边界)。只开放在全局路由,写操作须用户确认;抽出 create_project 本体,连接由调用方注入。
- 实现(2026-10-03,分支 feat/agent-client-parity-p8f):projects.rs 抽出登记本体 `register_project_records(conn, input)`(连接注入),UI `create_project` 只做取连接的薄包装,MCP `register_project` 经 `register_project_at(conn, path, name)` 换算入参后调同一本体——边界只写一处。本体只写数据库:不建目录、不执行 git init、不接入;依次拒绝空 ID、空路径、相对路径、不存在的目录、空名称、同一目录已登记、已存在的 ID;重复检查与 profile+workspace 插入放在同一 IMMEDIATE 事务(沿用 envoy 先查后插的做法),并发登记同一目录时后到者等前者提交后再查。workspace 插入由 `INSERT OR IGNORE` 改为 `INSERT`,ID 冲突不再静默返回成功。MCP `register_project`(schema 单独成函数,插在 scan_unregistered_projects 之后):入参 path(Windows 绝对路径,必填)与可选 name(缺省取目录名);project_id 由炼境生成 UUID(与 UI 选目录新建一致),归入当前空间(UI 首页按当前空间列项目);返回 JSON 并列入 STRUCTURED_TOOLS,structuredContent 带 project_id、name、path、space_id,摘要写明「只写登记、尚未接入,接入先 plan_onboarding 预览、经用户确认再 onboard_project」。is_project_scoped_tool 显式排除 register_project(与 unregister_project、setup_gitea_repo 并列),项目路由 tools/list 不含它,项目路由调用返回 scope_violation;annotations 为非只读、非破坏、非幂等。描述写明调用前须向用户列出目标目录与名称并取得确认。
- 重复路径规范化(`projects::registration_path_key`,纯字面):去首尾空白 → `/` 统一为 `\` → 去 `\\?\` 长路径前缀(`\\?\UNC\x` 还原为 `\\x`)→ 合并重复分隔符(保留 UNC 开头的 `\\`)→ 去尾部分隔符 → 小写。比较对象是每条已有登记的 win_path 与 wsl_path 两列:Windows 路径原样;WSL 的 Linux 路径按 `\\wsl.localhost\<settings.wsl_distro>\…` 换算(与 mcp_inject::project_root_with_conn 同口径),wsl_path 已是 Windows/UNC 形式的原样使用。命中即拒绝,错误写明「该目录已登记为项目 project_id=<已有 ID>(<登记路径>)……请直接使用该 project_id」。
- 其他登记入口(UI 取舍):① 模板新建(create_project_from_template)要先建目录、再跑初始化命令、最后入库,过不了本体「目录须已存在」,所以保持原流程不变,只在建目录前复用 `ensure_path_not_registered`(目录已删但登记仍在时,不再新建出第二条同目录登记);② 批量添加(batch_create_projects)保持「跳过不报错」语义,同一目录已登记(含同批前项)计入 skipped,提示文案改为「ID 已存在或目录已登记」,不加存在性与绝对路径校验(扫描结果必为已存在目录);③ 挂载仓库(mount_repo)给出路径时同样拒绝同一目录。
- 行为变化(报备):UI「添加项目」此前允许不选目录、允许相对或不存在的路径、ID 冲突时静默成功;现在与 MCP 共用本体,这些情况都报错(空路径登记曾让接入链路把文件写进炼境进程 cwd,见 mcp_inject::validate_registered_path 的 2026-07-15 回归)。WSL 平台以 wsl_path 为准校验,Linux 路径的存在性经 `\\wsl.localhost` 判定,settings.wsl_distro 与实际发行版不符时会被拒绝(此时 project_root 同样会解析错,提前暴露)。
- verification: "2026-10-03:全量 cargo test 335 passed / 0 failed / 5 ignored(master 为 325,新增 10 条),编译 0 warning;pnpm typecheck 通过。projects 新增 7 条:registration_path_key 归并大小写、斜杠方向、尾部分隔符、重复分隔符、长路径前缀,UNC 保留双分隔符,不同目录不归并;空串、空白、相对路径(含 cwd 下真实存在的 src)、盘符相对路径、不存在的目录全部拒绝,零行写入、无孤儿 profile、不存在的目录未被创建;同一临时目录以大写、小写、正斜杠、尾部分隔符等 5 种写法及改走 WSL 平台再登记均被拒绝,错误含 project_id=first,表内仍 1 行,目录内没有 .git;WSL Linux 路径的登记能按 UNC 写法查到;ID 冲突报错且原登记不被改写;register_project_at 生成 UUID、名称取目录名、归入当前空间、存去掉首尾空白的路径,同目录再登记的错误含已有 project_id;批量添加跳过已登记目录与同批重复目录。mcp_tools 新增 3 条:全局 tools/list 含 register_project(required=[path]、非只读非破坏、描述含用户确认、不建目录、git init、project_id),项目路由 tools/list 不含,schema 加上 project_id 后 is_project_scoped_tool 仍为 false;项目路由调用 register_project 返回 scope_violation;成功回执 structuredContent 带 project_id、摘要写明尚未接入。变异验证(先暂存,改源码,跑 projects:: 与 mcp_tools::,再从暂存区还原)9 处全部被杀:去掉重复目录检查(改 IMMEDIATE 事务前后各验一次)、比较键不小写、不去尾部分隔符、不统一斜杠方向、去掉目录存在校验、去掉绝对路径校验、批量添加不跳过同目录、is_project_scoped_tool 不排除 register_project。"
- 没验什么:① acceptance 的 [MCP 全局] 链路 register_project → plan_onboarding → onboard_project 依赖 P8e 才有的两个 MCP 工具,本卡只实现与测试 register_project;**链路实测待 P8e 合并后由统筹会话完成**(要在运行中的炼境上登记一个临时 git 仓库,属写运行中 DB,按授权规则处理),卡片保持 🔵。② 没在运行中的炼境上调过 register_project(未重编译安装,且会写运行中 DB)。③ IMMEDIATE 事务防并发重复只有结构保证,没有并发测试。④ 模板新建与挂载仓库的重复目录检查没有单测(Tauri command 直接取连接池,模板路径还要建目录、跑命令),由共用的 find_registration_by_path 测试间接覆盖。⑤ UI 添加项目的报错只看了代码路径(ProjectModal 以 toast 显示错误),没有人工点过。⑥ 本卡为 S 级,未做独立审查。
- known_limits: "① 规范化只做字面:`..`、符号链接、junction、8.3 短名与长名不归一;WSL 路径也按不区分大小写比较(Linux 上仅大小写不同的两个目录会被误判为同一目录,极少见)。② update_project 把路径改成一个已登记目录不拦(不在本卡范围);存量的同目录重复登记(如待用户处理的 beast-mode / 940dfbb4)不受影响,仍按统筹文档 §3 由用户执行数据合并。③ 批量添加的 wsl_convert 模式把 E:\x 存成 /mnt/e/x,与同目录的 Windows 登记比较键不同,查不出这类跨写法重复。④ scan_unregistered_projects 判定已登记仍是区分大小写的字面比较,可能把只差大小写的已登记目录列成未登记;对它调 register_project 会被本卡的重复检查拒绝并给出已有 project_id。"
- 有意偏差:① 未加 confirmed 参数——任务要求是「描述写明写操作须用户确认目标」;登记只写炼境 DB、可经 unregister_project 撤销,按 unregister_project 的先例只在描述里约束。② MCP 不收 project_id,统一由炼境生成 UUID,避免 agent 自拟 ID 与已有命名冲突。③ 重复目录检查扩到批量添加、模板新建、挂载仓库三个入口(files 标「实施扩展」):PR #34 审查 L1/L2 的根因是同一目录多条登记,只堵 create_project 和 MCP 两个入口治不了根。
- T1:新 MCP 工具 register_project 已同步 docs/ai-context/project-context.yaml「MCP 工具接口」段(paths 补 projects.rs,doc 补路由、本体、规范化与入参语义);Tauri command 签名未变,src/lib/commands.ts 无需改动。
### 📋 P8g — UI 一键接入
- status: todo

View File

@ -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:

View File

@ -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

View File

@ -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<ProjectDetail, String> {
})
}
#[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\<distro>\…` 换算(与 `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<std::path::PathBuf, String> {
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<Option<(String, String)>, 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<String>>(1)?,
row.get::<_, Option<String>>(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<Option<(String, String)>, 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<String>,
}
/// 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<RegisteredProject, String> {
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<String> = 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<String>,
}
/// 批量创建项目(已存在的 workspace id 跳过)
/// 批量创建项目(已存在的 workspace id 或已登记的同一目录跳过)
#[tauri::command]
pub fn batch_create_projects(items: Vec<BatchProjectInput>) -> Result<Vec<String>, 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<BatchProjectInput>,
) -> Result<Vec<String>, 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<BatchProjectInput>) -> Result<Vec<String
|_| Ok(true),
)
.unwrap_or(false);
if exists {
let same_dir = duplicate_registration_of(
conn,
item.platform.as_deref(),
item.win_path.as_deref(),
item.wsl_path.as_deref(),
)?
.is_some();
if exists || same_dir {
skipped.push(item.id);
continue;
}
@ -686,4 +929,272 @@ mod tests {
assert!(store.get(&gone_ref).unwrap().is_none());
assert_eq!(gitea_binding_count(&conn, "kept"), 1);
}
// ── 登记本体(P8f)────────────────────────────────────────────────────────
/// 临时目录(不是 git 仓库);测试结束由调用方清理
fn temp_project_dir(tag: &str) -> 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(&registered.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<String>) = 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(&registered.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();
}
}

View File

@ -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");

View File

@ -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::<Value>(&text) {
@ -1208,6 +1240,13 @@ async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<Str
}
"list_all_projects" => 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(&registered).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");

View File

@ -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} 个项目 ✓`);
}