dev-manager-tauri/src-tauri/src/mcp_tools.rs
lanrtop 8bc33aaab9
All checks were successful
Push & PR Check / check (push) Successful in 1m18s
Push & PR Check / check (pull_request) Successful in 58s
fix(delivery): distinguish skipped production deploy
2026-09-23 11:08:10 +09:00

2442 lines
107 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

use serde_json::{json, Value};
use crate::db;
// ── 工具 Schema 定义 ──────────────────────────────────────────────────────────
pub fn tools_list_result() -> Value {
let mut result = json!({
"tools": [
{
"name": "get_diagnostics",
"description": "查询炼境运行时诊断信息:MCP Server 启动状态、各项目 MCP 配置注入结果、最近错误日志。排查功能是否正常时首先调用此工具。",
"inputSchema": { "type": "object", "properties": {}, "required": [] }
},
{
"name": "list_group_projects",
"description": "列出产品组内所有成员项目,包含名称、路径、平台、技术栈、角色等信息(group_id 可从 list_all_projects 获取)",
"inputSchema": {
"type": "object",
"properties": {
"group_id": { "type": "string", "description": "产品组 ID" }
},
"required": ["group_id"]
}
},
{
"name": "get_project_blueprint",
"description": "读取产品组内某个项目的蓝图文件(.blueprint/manifest.yaml),了解该项目的模块与任务状态",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "get_project_file",
"description": "读取产品组内某个项目的任意文件内容,如 openapi.yaml、README.md、src/types.ts 等",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID" },
"relative_path": { "type": "string", "description": "相对于项目根目录的文件路径,如 openapi.yaml 或 src/api/routes.ts" }
},
"required": ["project_id", "relative_path"]
}
},
{
"name": "get_project_mentor_context",
"description": "获取产品组内某个项目的完整导师上下文——包含健康状态、蓝图进度摘要、近期错误日志,以及格式化好的 Markdown 报告(可直接用于质量分析和成长建议)",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "list_envoy_returns",
"description": "列出所有子项目通过「炼境使者」发回炼境的待处理洞察,按项目分组展示。炼境会话启动时调用,用于检查是否有需要处置的治理反馈(模板 bug、验证有效的实践、体系改进假设)。无需任何参数。",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "append_project_note",
"description": "将 Claude 的分析结论或复盘记录写入项目笔记,供未来会话和用户在炼境中查阅。完成 M/L 复杂度任务后必须调用,写入结构化复盘笔记。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"content": {
"type": "string",
"description": "笔记内容。完成 M/L 任务后使用以下结构化格式:\n【复盘】<任务标题>\n任务类型: 后端命令|前端组件|数据层|MCP工具|架构调整\n复杂度: S|M|L\n实际轮数: N轮(预估M轮)\n是否返工: 是/否\n返工原因: <若返工填写,否则省略>\n有效策略: <本次奏效的做法>\n遗留风险: <若有填写,否则省略>\n\n其他场景(分析结论、建议、洞察)可自由格式。"
}
},
"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 项目卡片上的蓝图状态徽章。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "generate_blueprint_prompt",
"description": "生成蓝图初始化/同步提示词(含 CONVENTIONS 规范与 manifest 严格格式约束)。等价于炼境 UI 治理面板的「初始化蓝图/更新蓝图描绘」按钮。拿到提示词后在该项目目录下执行它(或交给该项目的 AI 会话),完成后可用 get_blueprint_status / get_project_blueprint 验证结果。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"mode": { "type": "string", "enum": ["init", "sync"], "description": "init=项目尚无蓝图时从零初始化;sync=已有蓝图,对照代码现状同步更新" }
},
"required": ["project_id", "mode"]
}
},
{
"name": "sync_blueprint_rules",
"description": "将最新 CONVENTIONS 规则同步到项目(写入 .blueprint/CONVENTIONS.md + 更新 AGENTS.md/CLAUDE.md 的蓝图管理区块)。等价于炼境 UI 治理面板的「同步规则」按钮。仅当 get_blueprint_status 返回 rules_outdated 时需要调用。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "create_pull_request",
"description": "通过炼境存储的 Gitea 凭证为项目创建合并请求(PR)。炼境内部调用 Gitea API,无需 Claude 自行提取 token。/ship 交付流程在推送分支后应调用此工具完成 PR 创建。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"head": { "type": "string", "description": "源分支名,如 dev" },
"base": { "type": "string", "description": "目标分支名,如 main" },
"title": { "type": "string", "description": "PR 标题" },
"body": { "type": "string", "description": "PR 描述(Markdown),可为空字符串" }
},
"required": ["project_id", "head", "base", "title"]
}
},
{
"name": "get_project_snapshots",
"description": "获取项目的历史健康快照(每日一条),包含 git 提交间隔、蓝图完成率、阻塞任务数、错误情况。用于分析趋势、识别高价值改进变量。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"days": { "type": "integer", "description": "查询最近 N 天的快照,默认 30" }
},
"required": ["project_id"]
}
},
{
"name": "poll_now",
"description": "立即触发一次 Gitea 轮询:拉取所有绑定仓库的最新 commit 和 PR 状态写入飞轮。等价于炼境 UI「立即拉取」按钮。适用于公网 Gitea + 内网炼境(webhook 不可达)场景。",
"inputSchema": { "type": "object", "properties": {}, "required": [] }
},
{
"name": "setup_webhook",
"description": "为项目重新注册 Gitea webhook(订阅 push + pull_request 事件)。等价于炼境 UI「重设」按钮。导入新项目后、或需要更新事件订阅时调用。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "inject_mcp",
"description": "向项目的 .codex/config.toml 与 .claude/settings.json 注入炼境 MCP 配置。等价于炼境 UI「注入 MCP」按钮。导入新项目后或炼境重启后调用。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "get_ci_status",
"description": "查询指定 commit 的 CI 检查结果(pass/fail/pending)。/ship 流程推送分支并开 PR 后,用此工具确认 CI 是否通过再决定是否合并。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"commit_sha": { "type": "string", "description": "要查询的 commit SHA(完整 40 位或前 8 位均可)" }
},
"required": ["project_id", "commit_sha"]
}
},
{
"name": "get_delivery_status",
"description": "只读读取项目交付信号:本地 Git/PR/CI/review,以及项目声明的 production Gitea 部署 context 与公开 HTTP 版本探针。仅当部署成功、探针健康且运行 SHA 与 Gitea 默认分支目标 SHA 完全相同才返回 deployed;失败、跳过部署、部署成功后的漂移、未配置或来源不可达分别保留 failed/skipped/drifted/not_configured/unavailable,不替项目作发布决定。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_all_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "get_toolchain_status",
"description": "只读检查项目声明的 Node/pnpm 版本与当前实际 executable/version,并报告 mise resolver 状态。该工具只采集证据,不安装、升级或修改项目文件;缺失、冲突和版本漂移会返回明确状态。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_all_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "prepare_toolchain",
"description": "为已登记项目执行受控 mise install。仅在用户已明确确认本次机器状态变更后调用并传 confirmed=true;拒绝未知项目、冲突声明、自定义 mise task/hook/plugin,并且不修改 package.json 或 lockfile。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID" },
"confirmed": { "type": "boolean", "description": "当前用户是否已明确确认下载/安装项目声明的工具版本" }
},
"required": ["project_id", "confirmed"]
}
},
{
"name": "merge_pull_request",
"description": "在用户明确确认后合并指定 PR。能力本体会重新读取 PR、head SHA、CI 与 review;SHA 漂移、CI 未通过、changes requested、PR 非 open 或 confirmed=false 时拒绝执行。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"pr_number": { "type": "integer", "description": "PR 编号(来自 create_pull_request 的返回结果或 Gitea UI)" },
"expected_head_sha": { "type": "string", "description": "用户确认时看到的完整 40 位 PR head SHA;远端变化后旧确认自动失效" },
"confirmed": { "type": "boolean", "description": "当前用户是否已明确确认合并该 PR 与 expected_head_sha" }
},
"required": ["project_id", "pr_number", "expected_head_sha", "confirmed"]
}
},
{
"name": "get_pr_events",
"description": "查询项目的 PR 生命周期历史(opened/merged/closed)。数据来自炼境飞轮,本地 git 命令无法获取。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"limit": { "type": "integer", "description": "返回最近 N 条记录,默认 20" }
},
"required": ["project_id"]
}
},
{
"name": "get_commit_metrics",
"description": "查询项目的提交飞轮数据:提交类型分布(feat/fix/chore...)、是否返工、近期频率。数据来自炼境 DB,本地 git log 无法直接得到解析后的分类。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"days": { "type": "integer", "description": "查询最近 N 天,默认 30" }
},
"required": ["project_id"]
}
},
{
"name": "plan_cicd_workflow",
"description": "只读预览项目 CI workflow:返回目标文件、create/update/noop、完整内容摘要 digest、所需 secret 名称与工具链来源;不写磁盘、不配置 secret、不触发 CI 或部署。部署类模板只读取炼境 UI 已保存的配置。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"workflow_type": { "type": "string", "enum": ["pr_ci", "deepseek_review", "web_ssh", "tauri_oss"], "description": "要预览的 workflow 类型" }
},
"required": ["project_id", "workflow_type"]
}
},
{
"name": "apply_cicd_workflow",
"description": "在当前用户明确确认 plan_cicd_workflow 的摘要后,仅写入该项目对应的 .gitea/workflows 文件。必须提交同一预览的 digest;配置或内容变化会拒绝写入。不会配置 secret、触发 CI、SSH、部署、回滚或 migration。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"workflow_type": { "type": "string", "enum": ["pr_ci", "deepseek_review", "web_ssh", "tauri_oss"], "description": "与预览相同的 workflow 类型" },
"expected_digest": { "type": "string", "description": "用户确认时看到的 plan_cicd_workflow.content_digest" },
"confirmed": { "type": "boolean", "description": "当前用户是否已明确确认该预览摘要与写入目标" }
},
"required": ["project_id", "workflow_type", "expected_digest", "confirmed"]
}
},
{
"name": "distribute_cicd",
"description": "旧客户端兼容入口:直接写入 pr_ci 或 deepseek_review 模板。新 Agent 应使用 plan_cicd_workflow → 用户确认摘要 → apply_cicd_workflow;部署类模板不允许通过本入口写入。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"workflow_type": { "type": "string", "enum": ["pr_ci", "deepseek_review"], "description": "pr_ci=PR 合并前质量检查;deepseek_review=AI 代码审查并评论到 PR" }
},
"required": ["project_id", "workflow_type"]
}
},
{
"name": "list_all_projects",
"description": "列出炼境中所有已登记项目(不限产品组),包含 project_id、名称、路径、所属产品组、MCP 注入状态。用于发现尚未注入的项目,再调 inject_mcp 完成接入。",
"inputSchema": { "type": "object", "properties": {}, "required": [] }
},
{
"name": "apply_onboarding_pack",
"description": "向项目分发标准接入包:写入 AGENTS.md / lefthook / git-workflow / envoy / blueprint-gate 模板(版本化块原位升级、全托管文件内容不一致即覆盖、lefthook 存在即跳过,块外用户内容绝不触碰),并挂载 buff 自动监听 git 提交。等价于炼境 UI「接入包」区域的「应用接入包」按钮。模板升级后对存量项目重新调用即可推送新版本;不传 project_id 则对所有已登记项目批量执行。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects)。省略则批量分发到所有已登记项目" }
},
"required": []
}
},
{
"name": "verify_onboarding",
"description": "接入生效验收:检查接入包在项目里是否真正生效(含工具无关项目上下文、基础能力包版本、Codex MCP/skill、模板管理块、lefthook、origin、CI 与飞轮数据)。每项返回 ready / missing / legacy / version_mismatch / unavailable 的真实状态和升级提示;分发成功 ≠ 功能生效,apply_onboarding_pack 或 inject_mcp 之后应调用本工具验收。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" }
},
"required": ["project_id"]
}
},
{
"name": "scan_unregistered_projects",
"description": "扫描已登记项目的父目录,找出存在于文件系统但尚未在炼境注册的项目目录。返回每个未注册目录的路径、是否有 .git、技术栈特征文件(package.json / Cargo.toml / go.mod)、是否有 AGENTS.md。用于日常发现漏网项目并决定是否接入炼境。",
"inputSchema": { "type": "object", "properties": {}, "required": [] }
},
{
"name": "unregister_project",
"description": "从炼境删除一条项目登记(等价 UI 删除项目按钮的 agent 投影):清理产品组 MCP 配置、关联表、workspace 与孤儿 profile。绝不删除本地文件(该能力不对 agent 开放)。适用:scan_unregistered_projects 的反向操作——清理目录已消失或已无 .git 的死登记。删除不可逆,调用前先向用户列出目标清单确认。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "要删除登记的项目 workspace ID" }
},
"required": ["project_id"]
}
},
{
"name": "ingest_git_history",
"description": "全量导入 git 历史到飞轮(等价 UI「全量导入」按钮的 agent 投影):解析最近 500 条 commit 的 conventional 指标、Rules-Applied trailer 与触碰文件,UPSERT 幂等(存量行自动回填 trailer)。不传 project_id 时对所有已登记 Windows 路径项目执行。适用:trailer 存量回填、新项目接入后初始化飞轮数据。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "可选。项目 workspace ID;省略则遍历所有已登记项目" }
},
"required": []
}
},
{
"name": "predict_task_risk",
"description": "任务风险预测(飞轮阶段三):按 scope 和/或文件清单查询该项目历史返工率,返回 risk_level(low/medium/high)、样本量与置信度、热点文件排行。agent 开工前调用——任务卡涉及高返工 scope/文件时提高警惕(多写测试、小步提交)。样本 <5 或 commit 历史不规范时 confidence=low,结论仅供参考。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"scope": { "type": "string", "description": "conventional commit scope(如 onboarding、auth/login),前缀匹配子 scope" },
"files": { "type": "array", "items": { "type": "string" }, "description": "任务卡涉及的文件相对路径清单(与 git log --name-only 路径格式一致)" }
},
"required": ["project_id"]
}
},
{
"name": "setup_gitea_repo",
"description": "为项目在默认 Gitea 实例上创建远程仓库,并将本地 git remote origin 指向新仓库(幂等:仓库已存在则直接绑定,remote 已有则 set-url 更新)。创建后炼境自动记录绑定关系,后续可调用 setup_webhook 注册 webhook。需先在炼境 UI 配置一个默认 Gitea 实例。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_group_projects 的返回结果)" },
"repo_name": { "type": "string", "description": "Gitea 仓库名;省略则用项目名自动 slug 化(非 ASCII 字符过滤掉)" },
"private": { "type": "boolean", "description": "是否创建私有仓库,默认 false(公开)" }
},
"required": ["project_id"]
}
}
]
});
apply_tool_annotations(&mut result);
result
}
fn is_project_scoped_tool(tool: &Value) -> bool {
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
if name == "get_diagnostics" {
return true;
}
if matches!(name, "unregister_project" | "setup_gitea_repo") {
return false;
}
tool.get("inputSchema")
.and_then(|schema| schema.get("properties"))
.and_then(|properties| properties.get("project_id"))
.is_some()
}
pub fn tools_list_result_for_project(project_id: &str) -> Value {
let mut result = tools_list_result();
let Some(tools) = result.get_mut("tools").and_then(Value::as_array_mut) else {
return result;
};
tools.retain(is_project_scoped_tool);
for tool in tools {
let Some(schema) = tool.get_mut("inputSchema").and_then(Value::as_object_mut) else {
continue;
};
if let Some(project_property) = schema
.get_mut("properties")
.and_then(Value::as_object_mut)
.and_then(|properties| properties.get_mut("project_id"))
.and_then(Value::as_object_mut)
{
project_property.insert("const".to_string(), json!(project_id));
project_property.insert(
"description".to_string(),
json!(format!(
"当前连接已绑定 project_id={project_id};可省略,传入其他值会被拒绝"
)),
);
}
if let Some(required) = schema.get_mut("required").and_then(Value::as_array_mut) {
required.retain(|field| field.as_str() != Some("project_id"));
}
}
result
}
fn scope_violation(detail: impl Into<String>) -> Value {
json!({
"content": [{ "type": "text", "text": detail.into() }],
"isError": true,
"structuredContent": { "code": "scope_violation" }
})
}
fn bind_project_scope(tool_name: &str, args: &mut Value, project_id: &str) -> Result<(), String> {
let catalog = tools_list_result_for_project(project_id);
let allowed = catalog["tools"]
.as_array()
.map(|tools| tools.iter().any(|tool| tool["name"] == tool_name))
.unwrap_or(false);
if !allowed {
return Err(format!(
"scope_violation: 工具 {tool_name} 不允许通过项目作用域连接调用"
));
}
let object = args
.as_object_mut()
.ok_or_else(|| "scope_violation: arguments 必须是 JSON 对象".to_string())?;
if let Some(explicit) = object.get("project_id").and_then(Value::as_str) {
if explicit != project_id {
return Err(format!(
"scope_violation: 当前连接绑定 project_id={project_id},拒绝访问 project_id={explicit}"
));
}
}
object.insert("project_id".to_string(), json!(project_id));
Ok(())
}
fn apply_tool_annotations(result: &mut Value) {
let Some(tools) = result.get_mut("tools").and_then(Value::as_array_mut) else {
return;
};
for tool in tools {
let name = tool.get("name").and_then(Value::as_str).unwrap_or_default();
let read_only = name.starts_with("get_")
|| name.starts_with("list_")
|| name.starts_with("scan_")
|| name.starts_with("plan_")
|| name.starts_with("predict_")
|| name == "verify_onboarding"
|| name == "generate_blueprint_prompt";
let destructive = matches!(
name,
"unregister_project"
| "merge_pull_request"
| "apply_cicd_workflow"
| "distribute_cicd"
| "prepare_toolchain"
| "setup_gitea_repo"
| "create_pull_request"
);
let idempotent = read_only
|| matches!(
name,
"inject_mcp"
| "apply_onboarding_pack"
| "setup_webhook"
| "sync_blueprint_rules"
| "backfill_notes_archive"
| "ingest_git_history"
);
let open_world = matches!(
name,
"create_pull_request"
| "get_ci_status"
| "get_delivery_status"
| "merge_pull_request"
| "setup_webhook"
| "setup_gitea_repo"
);
if let Some(object) = tool.as_object_mut() {
object.insert(
"annotations".to_string(),
json!({
"readOnlyHint": read_only,
"destructiveHint": destructive,
"idempotentHint": idempotent,
"openWorldHint": open_world
}),
);
}
}
}
fn structured_tool_summary(tool_name: &str, value: &Value) -> String {
match tool_name {
"get_delivery_status" => format!(
"交付状态:project_id={},Git={},PR={},CI={},review={},deployment={}",
value["project_id"].as_str().unwrap_or("?"),
value["git"]["state"].as_str().unwrap_or("unknown"),
value["pull_request"]["state"].as_str().unwrap_or("unknown"),
value["ci"]["state"].as_str().unwrap_or("unknown"),
value["review"]["state"].as_str().unwrap_or("unknown"),
value["deployment"]["state"].as_str().unwrap_or("unknown")
),
"get_toolchain_status" => format!(
"工具链状态:project_id={},Node={},pnpm={},resolver={},冲突={} 项",
value["project_id"].as_str().unwrap_or("?"),
value["node"]["status"].as_str().unwrap_or("unknown"),
value["pnpm"]["status"].as_str().unwrap_or("unknown"),
value["resolver"]["status"].as_str().unwrap_or("unknown"),
value["conflicts"].as_array().map(Vec::len).unwrap_or(0)
),
"verify_onboarding" => value["summary"]
.as_str()
.unwrap_or("接入验收结构化结果已返回")
.to_string(),
_ => "结构化结果已返回".to_string(),
}
}
fn tool_success_response(tool_name: &str, text: String) -> Value {
const STRUCTURED_TOOLS: &[&str] = &[
"get_delivery_status",
"get_toolchain_status",
"verify_onboarding",
];
if STRUCTURED_TOOLS.contains(&tool_name) {
if let Ok(structured) = serde_json::from_str::<Value>(&text) {
let summary = structured_tool_summary(tool_name, &structured);
return json!({
"content": [{ "type": "text", "text": summary }],
"structuredContent": structured
});
}
}
json!({ "content": [{ "type": "text", "text": text }] })
}
// ── 工具分发 ──────────────────────────────────────────────────────────────────
pub async fn tools_call(params: Option<&Value>) -> Value {
tools_call_with_scope(params, None).await
}
pub async fn tools_call_for_project(params: Option<&Value>, project_id: &str) -> Value {
tools_call_with_scope(params, Some(project_id.to_string())).await
}
async fn tools_call_with_scope(params: Option<&Value>, project_scope: Option<String>) -> Value {
let tool_name = params
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
let mut args = params
.and_then(|p| p.get("arguments"))
.cloned()
.unwrap_or(json!({}));
if let Some(project_id) = project_scope.as_deref() {
if let Err(detail) = bind_project_scope(&tool_name, &mut args, project_id) {
return scope_violation(detail);
}
}
let response_tool_name = tool_name.clone();
let scope_for_call = project_scope.clone();
let outcome = tokio::task::spawn_blocking(move || match tool_name.as_str() {
"get_diagnostics" => match scope_for_call.as_deref() {
Some(project_id) => get_scoped_diagnostics(project_id),
None => get_diagnostics(),
},
"list_group_projects" => {
let group_id = args
.get("group_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty());
match group_id {
Some(g) => list_group_projects(g),
None => Err("需显式传 group_id 参数(组 ID 可从 list_all_projects 获取)".to_string()),
}
}
"get_project_blueprint" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
get_project_blueprint(pid)
}
"get_blueprint_status" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
resolve_project_root(pid).and_then(|root| {
crate::commands::blueprint::get_blueprint_status(root.to_string_lossy().to_string())
.and_then(|s| serde_json::to_string_pretty(&s).map_err(|e| e.to_string()))
})
}
"generate_blueprint_prompt" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let mode = args.get("mode").and_then(|v| v.as_str()).unwrap_or("sync");
if mode != "init" && mode != "sync" {
Err(format!("mode 只允许 init 或 sync,收到: {mode}"))
} else {
resolve_project_root(pid).and_then(|root| {
crate::commands::blueprint::generate_blueprint_prompt(
root.to_string_lossy().to_string(),
mode.to_string(),
)
})
}
}
"sync_blueprint_rules" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
resolve_project_root(pid).and_then(|root| {
crate::commands::blueprint::sync_blueprint_rules(root.to_string_lossy().to_string())
.and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string()))
})
}
"get_project_file" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let rel = args.get("relative_path").and_then(|v| v.as_str()).unwrap_or("");
get_project_file(pid, rel)
}
"get_project_mentor_context" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
get_project_mentor_context(pid)
}
"list_envoy_returns" => {
list_envoy_returns()
}
"append_project_note" => {
let pid = args.get("project_id").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)
}
"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;
get_project_snapshots(pid, days)
}
"create_pull_request" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let head = args.get("head").and_then(|v| v.as_str()).unwrap_or("");
let base = args.get("base").and_then(|v| v.as_str()).unwrap_or("main");
let title = args.get("title").and_then(|v| v.as_str()).unwrap_or("");
let body = args.get("body").and_then(|v| v.as_str()).unwrap_or("");
crate::commands::gitea::gitea_api_create_pull_request(pid, head, base, title, body)
}
"poll_now" => crate::commands::gitea::poll_all_repos()
.map(|(polled, ingested)| format!("轮询完成:{polled} 个仓库,新 commit {ingested} 条,PR 事件已同步")),
"setup_webhook" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let port = crate::mcp_server::read_port();
let callback_base = format!("http://127.0.0.1:{}", port);
crate::commands::gitea::gitea_setup_webhook(pid.to_string(), callback_base).map(|repo| {
format!(
"webhook 注册成功:{}/{} hook_id={}",
repo.gitea_owner,
repo.gitea_repo_name,
repo.webhook_id.map(|id| id.to_string()).unwrap_or_default()
)
})
}
"inject_mcp" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
crate::mcp_inject::inject_project_mcp(pid)
.map(|_| format!("MCP 注入成功:project_id={}(已配置 Codex 与 Claude;单一 lian-jing 服务器,无需 group_id)", pid))
}
"get_ci_status" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let sha = args.get("commit_sha").and_then(|v| v.as_str()).unwrap_or("");
if sha.is_empty() {
return Err("commit_sha 不能为空".to_string());
}
crate::commands::gitea::gitea_api_get_ci_status(pid, sha)
}
"get_delivery_status" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
if pid.is_empty() {
return Err("project_id 不能为空".to_string());
}
let root = resolve_project_root(pid)?;
let conn = db::pool().get().map_err(|e| e.to_string())?;
let status = crate::commands::delivery_status::get_delivery_status(&conn, pid, &root);
serde_json::to_string_pretty(&status).map_err(|e| e.to_string())
}
"get_toolchain_status" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
if pid.is_empty() {
return Err("project_id 不能为空".to_string());
}
let conn = db::pool().get().map_err(|e| e.to_string())?;
let status = crate::commands::toolchain::get_toolchain_status_for(&conn, pid)?;
serde_json::to_string_pretty(&status).map_err(|e| e.to_string())
}
"prepare_toolchain" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
if pid.is_empty() {
return Err("project_id 不能为空".to_string());
}
let confirmed = args
.get("confirmed")
.and_then(|value| value.as_bool())
.unwrap_or(false);
let conn = db::pool().get().map_err(|e| e.to_string())?;
let result =
crate::commands::toolchain::prepare_toolchain_for(&conn, pid, confirmed)?;
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())
}
"merge_pull_request" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let pr_num = args.get("pr_number").and_then(|v| v.as_i64()).unwrap_or(0);
let expected_head_sha = args
.get("expected_head_sha")
.and_then(|v| v.as_str())
.unwrap_or("");
let confirmed = args
.get("confirmed")
.and_then(|value| value.as_bool())
.unwrap_or(false);
if pid.is_empty() {
return Err("project_id 不能为空".to_string());
}
if pr_num <= 0 {
return Err("pr_number 必须为正整数".to_string());
}
if !confirmed {
return Err("合并需要当前用户明确确认(confirmed=true)".to_string());
}
if expected_head_sha.is_empty() {
return Err("expected_head_sha 不能为空".to_string());
}
let conn = db::pool().get().map_err(|e| e.to_string())?;
crate::commands::gitea::gitea_api_merge_pull_request(
&conn,
pid,
pr_num,
expected_head_sha,
confirmed,
)
}
"get_pr_events" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize;
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT pr_number, title, action, head_branch, base_branch, author, event_at
FROM pr_events WHERE project_id = ?1 ORDER BY event_at DESC LIMIT ?2",
)
.map_err(|e| e.to_string())?;
let rows: Vec<String> = stmt
.query_map(rusqlite::params![pid, limit as i64], |r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, String>(5)?,
r.get::<_, String>(6)?,
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.map(|(num, title, action, head, base, author, at)| {
let icon = match action.as_str() {
"merged" => "✅",
"closed" => "🚫",
_ => "🔵",
};
format!("{icon} PR #{num} [{action}] {title}\n {head} → {base} by {author} @ {at}")
})
.collect();
let fresh_line = match db::read_sync_state(&conn, "flywheel") {
Some((at, mins)) => format!(
"⏱ 飞轮数据最近同步:{at} UTC(约 {mins} 分钟前)。数据可能滞后——PR 是否已存在以 create_pull_request 返回的 already_exists 为准。\n\n"
),
None => "⏱ 飞轮数据同步时间未知(尚无同步记录,可先调 poll_now)。\n\n".to_string(),
};
if rows.is_empty() {
Ok(format!("{fresh_line}项目 {} 暂无 PR 事件记录", pid))
} else {
Ok(format!("{fresh_line}PR 事件(最近 {} 条):\n\n{}", rows.len(), rows.join("\n\n")))
}
}
"get_commit_metrics" => {
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);
let conn = db::pool().get().map_err(|e| e.to_string())?;
let offset = format!("-{} days", days);
let total: i64 = conn
.query_row(
"SELECT COUNT(*) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)",
rusqlite::params![pid, offset],
|r| r.get(0),
)
.unwrap_or(0);
let mut type_stmt = conn
.prepare(
"SELECT COALESCE(commit_type,'unknown') as t, COUNT(*) as n, SUM(is_rework) as rework
FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)
GROUP BY t ORDER BY n DESC",
)
.map_err(|e| e.to_string())?;
let type_rows: Vec<String> = type_stmt
.query_map(rusqlite::params![pid, offset], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.map(|(t, n, rework)| format!(" - {t}: {n} 条(返工 {rework} 条)"))
.collect();
let rework_total: i64 = conn
.query_row(
"SELECT SUM(is_rework) FROM commit_metrics WHERE project_id = ?1 AND committed_at >= datetime('now', ?2)",
rusqlite::params![pid, offset],
|r| r.get(0),
)
.unwrap_or(0);
let mut out = format!("## 提交飞轮数据(最近 {} 天)\n\n", days);
match db::read_sync_state(&conn, "flywheel") {
Some((at, mins)) => out.push_str(&format!(
"⏱ 飞轮数据最近同步:{at} UTC(约 {mins} 分钟前)\n\n"
)),
None => out.push_str("⏱ 飞轮数据同步时间未知(尚无同步记录,可先调 poll_now)\n\n"),
}
out.push_str(&format!("- **总提交数**:{}\n", total));
out.push_str(&format!(
"- **返工提交**:{} 条({:.0}%)\n",
rework_total,
if total > 0 { rework_total as f64 / total as f64 * 100.0 } else { 0.0 }
));
out.push_str("\n**按类型分布**:\n");
out.push_str(&type_rows.join("\n"));
Ok(out)
}
"distribute_cicd" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let wt = args.get("workflow_type").and_then(|v| v.as_str()).unwrap_or("");
crate::commands::cicd::distribute_workflow_for_project(pid, wt)
}
"plan_cicd_workflow" => {
let pid = args.get("project_id").and_then(|value| value.as_str()).unwrap_or("");
let workflow_type = args
.get("workflow_type")
.and_then(|value| value.as_str())
.unwrap_or("");
if pid.is_empty() {
return Err("project_id 不能为空".to_string());
}
if workflow_type.is_empty() {
return Err("workflow_type 不能为空".to_string());
}
let conn = db::pool().get().map_err(|error| error.to_string())?;
let plan = crate::commands::cicd::plan_cicd_workflow_for(
&conn,
pid,
workflow_type,
)?;
serde_json::to_string_pretty(&plan).map_err(|error| error.to_string())
}
"apply_cicd_workflow" => {
let pid = args.get("project_id").and_then(|value| value.as_str()).unwrap_or("");
let workflow_type = args
.get("workflow_type")
.and_then(|value| value.as_str())
.unwrap_or("");
let expected_digest = args
.get("expected_digest")
.and_then(|value| value.as_str())
.unwrap_or("");
let confirmed = args
.get("confirmed")
.and_then(|value| value.as_bool())
.unwrap_or(false);
if pid.is_empty() {
return Err("project_id 不能为空".to_string());
}
if workflow_type.is_empty() {
return Err("workflow_type 不能为空".to_string());
}
if !confirmed {
return Err("写入 CI workflow 需要当前用户明确确认(confirmed=true)".to_string());
}
if expected_digest.is_empty() {
return Err("expected_digest 不能为空;请先调用 plan_cicd_workflow".to_string());
}
let conn = db::pool().get().map_err(|error| error.to_string())?;
let result = crate::commands::cicd::apply_cicd_workflow_for(
&conn,
pid,
workflow_type,
expected_digest,
confirmed,
)?;
serde_json::to_string_pretty(&result).map_err(|error| error.to_string())
}
"list_all_projects" => list_all_projects(),
"scan_unregistered_projects" => scan_unregistered_projects(),
"unregister_project" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
if pid.is_empty() {
Err("project_id 不能为空".to_string())
} else {
// delete_local 硬编码 false:MCP 侧永不删除本地文件
crate::commands::projects::delete_project(pid.to_string(), Some(false))
.map(|_| format!("已删除登记:{}(本地文件未动)", pid))
}
}
"ingest_git_history" => {
let pid = args.get("project_id").and_then(|v| v.as_str());
let conn = db::pool().get().map_err(|e| e.to_string())?;
let targets: Vec<(String, String)> = {
let mut stmt = conn
.prepare(
"SELECT id, win_path FROM project_workspaces
WHERE win_path IS NOT NULL AND (?1 IS NULL OR id = ?1)",
)
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map(rusqlite::params![pid], |r| Ok((r.get(0)?, r.get(1)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
rows
};
drop(conn);
if targets.is_empty() {
Err(format!("未找到可导入的项目(project_id={:?})", pid))
} else {
let mut lines = Vec::new();
let (mut ok, mut failed) = (0u32, 0u32);
for (id, path) in &targets {
match crate::commands::commit_metrics::ingest_full_git_history(
id.clone(),
path.clone(),
) {
Ok(r) => {
ok += 1;
lines.push(format!(
"- {}: 写入 {} 条,跳过 {} 条,清洗 {} 条无效规则ID",
id, r.imported, r.skipped, r.sanitized
));
}
Err(e) => {
failed += 1;
lines.push(format!("- {}: ❌ {}", id, e));
}
}
}
Ok(format!(
"全量导入完成:{} 个项目成功,{} 个失败\n\n{}",
ok,
failed,
lines.join("\n")
))
}
}
"predict_task_risk" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let scope = args.get("scope").and_then(|v| v.as_str());
let files: Vec<String> = args
.get("files")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|f| f.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let conn = db::pool().get().map_err(|e| e.to_string())?;
crate::commands::risk::predict_task_risk(&conn, pid, scope, &files)
.and_then(|r| serde_json::to_string_pretty(&r).map_err(|e| e.to_string()))
}
"setup_gitea_repo" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let repo_name = args.get("repo_name").and_then(|v| v.as_str());
let private = args.get("private").and_then(|v| v.as_bool()).unwrap_or(false);
crate::commands::gitea::gitea_api_setup_repo(pid, repo_name, private)
}
// U1 批量模式:project_id 字段缺失(而非空串——空串按无效单项目报错)→ 全部已登记项目
"apply_onboarding_pack" if args.get("project_id").and_then(|v| v.as_str()).is_none() => {
// 复用 ingest_git_history 先例;替代曾经的 batch_apply.py 临时脚本
let conn = db::pool().get().map_err(|e| e.to_string())?;
let targets: Vec<(String, String)> = {
let mut stmt = conn
.prepare("SELECT id, win_path FROM project_workspaces WHERE win_path IS NOT NULL AND TRIM(win_path) != ''")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
rows
};
drop(conn);
if targets.is_empty() {
Err("无已登记的 Windows 路径项目可分发".to_string())
} else {
let mut lines = Vec::new();
let (mut ok, mut failed) = (0u32, 0u32);
for (id, path) in &targets {
match crate::commands::onboarding::apply_onboarding_pack(
path.clone(),
id.clone(),
) {
Ok(report) => {
ok += 1;
let summary: Vec<String> = report
.templates
.iter()
.map(|t| {
let short = t.name.rsplit('/').next().unwrap_or(&t.name);
format!("{short}={}", t.action)
})
.collect();
lines.push(format!(
"- {}: {} | hook={}",
id,
summary.join(", "),
report.hook.status
));
}
Err(e) => {
failed += 1;
lines.push(format!("- {}: ❌ {}", id, e));
}
}
}
Ok(format!(
"批量接入包分发完成:{} 成功,{} 失败\n\n{}\n\n建议对关键项目调用 verify_onboarding 抽查生效。",
ok,
failed,
lines.join("\n")
))
}
}
"apply_onboarding_pack" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
resolve_project_root(pid).and_then(|root| {
let path = root.to_string_lossy().to_string();
crate::commands::onboarding::apply_onboarding_pack(path, pid.to_string()).map(
|report| {
let template_lines: Vec<String> = report
.templates
.iter()
.map(|t| {
let icon = match t.action.as_str() {
"written" => "✅ written",
"updated" => "🔄 updated",
"appended" => "➕ appended",
"skipped" => "⏭ skipped",
_ => "❌ failed",
};
format!(" {icon}: {}", t.name)
})
.collect();
let hook_icon = match report.hook.status.as_str() {
"activated" => "✅",
"missing_binary" => "⚠️",
_ => "❌",
};
format!(
"接入包分发完成(project_id={})\n\n模板:\n{}\n\nlefthook hook:{} {}({})\nbuff:{}\n\n建议调用 verify_onboarding 做生效验收。",
pid,
template_lines.join("\n"),
hook_icon,
report.hook.status,
report.hook.detail,
report.buff.status
)
},
)
})
}
"verify_onboarding" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
let root = resolve_project_root(pid)?;
let conn = db::pool().get().map_err(|e| e.to_string())?;
let checks = crate::commands::onboarding::verify_onboarding(&conn, pid, &root);
let runtime = crate::commands::settings::server_observed_runtime_status(pid, &root);
let report =
crate::commands::onboarding::summarize_onboarding(pid, checks, runtime);
serde_json::to_string_pretty(&report)
.map_err(|e| e.to_string())
}
_ => Err(format!("未知工具: {}", tool_name)),
})
.await;
match outcome {
Ok(Ok(text)) => tool_success_response(&response_tool_name, text),
Ok(Err(e)) => json!({ "content": [{ "type": "text", "text": e }], "isError": true }),
Err(e) => {
json!({ "content": [{ "type": "text", "text": e.to_string() }], "isError": true })
}
}
}
// ── 工具实现 ──────────────────────────────────────────────────────────────────
fn get_diagnostics() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let port = crate::mcp_server::read_port();
let start_time: Option<String> = conn
.query_row(
"SELECT ts FROM runtime_logs WHERE category = 'mcp_server' AND level = 'info' ORDER BY id ASC LIMIT 1",
[],
|r| r.get(0),
)
.ok();
let mut out = String::new();
out.push_str("## 炼境 MCP 诊断报告\n\n");
out.push_str(&format!("- **服务端口**: {}\n", port));
out.push_str(&format!(
"- **启动时间**: {}\n\n",
start_time
.as_deref()
.unwrap_or("未知(可能在本次日志之前启动)")
));
out.push_str("### MCP 注入状态(每个项目最后一次结果)\n\n");
let mut stmt = conn
.prepare(
"SELECT context, level, message, ts
FROM runtime_logs
WHERE category = 'mcp_inject'
AND id IN (
SELECT MAX(id) FROM runtime_logs
WHERE category = 'mcp_inject'
GROUP BY json_extract(context, '$.project_id')
)
ORDER BY ts DESC
LIMIT 50",
)
.map_err(|e| e.to_string())?;
let inject_rows: Vec<(Option<String>, String, String, String)> = stmt
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if inject_rows.is_empty() {
out.push_str("_暂无注入记录(成员加入产品组时会产生)_\n\n");
} else {
for (ctx, level, msg, ts) in &inject_rows {
let project_id = ctx
.as_deref()
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
.and_then(|v| {
v.get("project_id")
.and_then(|x| x.as_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "unknown".to_string());
let icon = if level == "info" { "✅" } else { "⚠️" };
out.push_str(&format!(
"- {} `{}` — {} _({})\n",
icon, project_id, msg, ts
));
}
out.push('\n');
}
out.push_str("### 最近错误日志(最多 20 条)\n\n");
let mut stmt2 = conn
.prepare(
"SELECT ts, category, level, message FROM runtime_logs
WHERE level IN ('error', 'warn')
ORDER BY id DESC LIMIT 20",
)
.map_err(|e| e.to_string())?;
let error_rows: Vec<(String, String, String, String)> = stmt2
.query_map([], |row| {
Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if error_rows.is_empty() {
out.push_str("_无错误记录_ ✅\n");
} else {
for (ts, cat, level, msg) in &error_rows {
out.push_str(&format!("- `[{}]` **{}** `{}` — {}\n", ts, level, cat, msg));
}
}
out.push_str("\n### 使者回传覆盖率(近 30 天)\n\n");
match crate::commands::onboarding::silent_projects(&conn) {
Ok(list) if list.is_empty() => {
out.push_str(
"_无沉默项目:有 commit 活动的已接入项目均有使者回传(或近期无活动)_ ✅\n",
);
}
Ok(list) => {
out.push_str("以下项目近 30 天有 commit 活动但 **0 条使者回传**——环的回流段在这些项目上是断的,考虑去该项目会话跑一次 `/envoy`:\n\n");
for (name, commits) in &list {
out.push_str(&format!("- **{}** — {} 条 commit,0 回传\n", name, commits));
}
}
Err(e) => out.push_str(&format!("_查询失败: {e}_\n")),
}
out.push_str("\n### 系统健康\n\n");
let report = crate::commands::settings::collect_health_report();
out.push_str(&format!("检查时间:{}\n\n", report.checked_at));
out.push_str("| 检查项 | 状态 | 详情 |\n|--------|------|------|\n");
for item in &report.items {
let icon = match item.status.as_str() {
"ok" => "✅",
"warn" => "⚠️",
"error" => "❌",
_ => "—",
};
out.push_str(&format!(
"| {} | {} {} | {} |\n",
item.name,
icon,
item.status,
item.detail.as_deref().unwrap_or("-")
));
}
out.push_str("\n### 质量指标\n\n");
let q = &report.quality;
out.push_str(&format!(
"- **休眠项目**(>30天未 push):{} 个\n",
q.dormant_projects.len()
));
for p in &q.dormant_projects {
out.push_str(&format!(" - {} ({} 天)\n", p.name, p.days_since_push));
}
out.push_str(&format!(
"- **孤立项目**(无分组 & 无 repo):{} 个\n",
q.orphaned_projects.len()
));
for p in &q.orphaned_projects {
out.push_str(&format!(" - {}\n", p.name));
}
out.push_str(&format!(
"- **MCP 注入异常产品组**(近 7 天):{} 个\n",
q.groups_with_inject_warn.len()
));
for gid in &q.groups_with_inject_warn {
out.push_str(&format!(" - `{}`\n", gid));
}
out.push_str(&format!(
"- **无本地路径项目**:{} 个\n",
q.projects_without_path
));
Ok(out)
}
fn get_scoped_diagnostics(project_id: &str) -> Result<String, String> {
let root = resolve_project_root(project_id)?;
let runtime = crate::commands::settings::server_observed_runtime_status(project_id, &root);
serde_json::to_string_pretty(&json!({
"project_id": project_id,
"scope": "project",
"runtime": runtime,
"detail": "当前诊断仅包含绑定项目;全局项目清单与其他项目状态已隔离"
}))
.map_err(|error| error.to_string())
}
fn list_group_projects(group_id: &str) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, pp.tech_stack, pgm.role
FROM project_group_map pgm
JOIN project_workspaces pw ON pw.id = pgm.project_id
JOIN project_profiles pp ON pp.id = pw.profile_id
WHERE pgm.group_id = ?1",
)
.map_err(|e| e.to_string())?;
let rows: Vec<String> = stmt
.query_map([group_id], |row| {
let id: String = row.get(0)?;
let name: String = row.get(1)?;
let platform: Option<String> = row.get(2)?;
let win_path: Option<String> = row.get(3)?;
let wsl_path: Option<String> = row.get(4)?;
let tech_stack: Option<String> = row.get(5)?;
let role: Option<String> = row.get(6)?;
Ok(format!(
"- **{}** (id: `{}`)\n 平台: {} | 技术栈: {}{}\n Windows 路径: {}\n WSL 路径: {}",
name,
id,
platform.as_deref().unwrap_or("unknown"),
tech_stack.as_deref().unwrap_or("未配置"),
role.map(|r| format!(" | 角色: {}", r)).unwrap_or_default(),
win_path.as_deref().unwrap_or("未配置"),
wsl_path.as_deref().unwrap_or("未配置"),
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok("该产品组暂无成员项目。".to_string());
}
Ok(format!(
"产品组成员(共 {} 个项目):\n\n{}",
rows.len(),
rows.join("\n\n")
))
}
fn get_project_blueprint(project_id: &str) -> Result<String, String> {
let root = resolve_project_root(project_id)?;
let path = root.join(".blueprint").join("manifest.yaml");
std::fs::read_to_string(&path).map_err(|e| format!("读取蓝图失败({}): {}", path.display(), e))
}
fn get_project_file(project_id: &str, relative_path: &str) -> Result<String, String> {
if relative_path.contains("..") {
return Err("路径不允许包含 \"..\"".to_string());
}
let root = resolve_project_root(project_id)?;
let path = root.join(relative_path);
std::fs::read_to_string(&path).map_err(|e| format!("读取文件失败({}): {}", path.display(), e))
}
/// 按 project_id 直查项目根目录(单一服务器寻址,不做组归属过滤)
pub fn resolve_project_root(project_id: &str) -> Result<std::path::PathBuf, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
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",
[project_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
);
let (win_path, wsl_path, platform) = row.map_err(|_| format!("项目 {} 不存在", project_id))?;
if platform.as_deref() == Some("wsl") {
let wsl = wsl_path.ok_or_else(|| "WSL 项目未配置 wsl_path".to_string())?;
let distro: String = conn
.query_row(
"SELECT value FROM settings WHERE key = 'wsl_distro'",
[],
|r| r.get(0),
)
.unwrap_or_else(|_| "Ubuntu".to_string());
let inner = wsl.trim_start_matches('/').replace('/', "\\");
Ok(std::path::PathBuf::from(format!(
"\\\\wsl.localhost\\{}\\{}",
distro, inner
)))
} else {
let win = win_path.ok_or_else(|| "Windows 项目未配置 win_path".to_string())?;
Ok(std::path::PathBuf::from(win))
}
}
/// 项目存在性检查(替代旧的组归属校验——单一服务器寻址后组不再是访问边界)
fn ensure_project_exists(conn: &rusqlite::Connection, project_id: &str) -> Result<(), String> {
let exists: bool = conn
.query_row(
"SELECT COUNT(*) FROM project_workspaces WHERE id = ?1",
[project_id],
|r| r.get::<_, i64>(0),
)
.map(|n| n > 0)
.unwrap_or(false);
if !exists {
return Err(format!("项目 {} 不存在", project_id));
}
Ok(())
}
fn get_project_mentor_context(project_id: &str) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
ensure_project_exists(&conn, project_id)?;
let ctx = crate::commands::mentor::get_mentor_context(project_id.to_string())
.map_err(|e| format!("获取导师上下文失败: {e}"))?;
Ok(ctx.mentor_markdown)
}
fn list_envoy_returns() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT pn.id, pp.name, pn.content, pn.created_at
FROM project_notes pn
JOIN project_workspaces pw ON pn.project_id = pw.id
JOIN project_profiles pp ON pw.profile_id = pp.id
WHERE pn.content LIKE '【炼境使者回传】%'
ORDER BY pn.created_at DESC
LIMIT 50",
)
.map_err(|e| e.to_string())?;
let rows: Vec<(i64, String, String, String)> = stmt
.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok("📭 暂无待处理的炼境使者回传".to_string());
}
let mut out = format!("📬 炼境使者回传(共 {} 条)\n\n", rows.len());
for (_id, project_name, content, created_at) in &rows {
let date = &created_at[..created_at.len().min(10)];
out.push_str(&format!(
"---\n**来源**: {} | **时间**: {}\n{}\n\n",
project_name, date, content
));
}
out.push_str("---\n💡 处置参考:fix_template → 改接入包模板文件;update_convention → 改 CONVENTIONS.md;log_for_review → 记录待观察,暂不动模板");
Ok(out)
}
fn append_project_note(project_id: &str, content: &str) -> Result<String, String> {
if content.trim().is_empty() {
return Err("笔记内容不能为空".to_string());
}
let conn = db::pool().get().map_err(|e| e.to_string())?;
ensure_project_exists(&conn, project_id)?;
conn.execute(
"INSERT INTO project_notes (project_id, source, content) VALUES (?1, 'mcp', ?2)",
rusqlite::params![project_id, content],
)
.map_err(|e| e.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<(i64, String)>> =
std::collections::BTreeMap::new();
for (id, pid, source, content, created_at) in &rows {
by_project
.entry(pid.clone())
.or_default()
.push((*id, note_jsonl_line(*id, pid, source, content, created_at)));
}
// 按「解析后的落盘路径」归并——同路径重复登记(mcp-single-server 已知遗留)指向同一文件,
// 若按 project_id 逐项目整写会互相覆盖(2026-09-11 真机回填实锤:后写覆盖先写丢 20 条)
let mut by_path: std::collections::BTreeMap<
std::path::PathBuf,
(Vec<String>, Vec<(i64, String)>),
> = std::collections::BTreeMap::new();
let mut report = String::from("## notes 档案回填(全量重建)\n\n");
let mut skipped = 0usize;
for (pid, entries) in by_project {
match notes_archive_path(conn, &pid) {
Ok(path) => {
let slot = by_path.entry(path).or_default();
slot.0.push(pid);
slot.1.extend(entries);
}
Err(e) => {
report.push_str(&format!(
"- ⚠️ {}:跳过({}),{} 条仅存 DB\n",
pid,
e,
entries.len()
));
skipped += 1;
}
}
}
let _guard = NOTES_ARCHIVE_LOCK.lock().map_err(|e| e.to_string())?;
let mut archived_notes = 0usize;
for (path, (pids, mut entries)) in by_path {
entries.sort_by_key(|(id, _)| *id);
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
}
let lines: Vec<&str> = entries.iter().map(|(_, l)| l.as_str()).collect();
std::fs::write(&path, lines.join("\n") + "\n").map_err(|e| e.to_string())?;
archived_notes += entries.len();
report.push_str(&format!(
"- ✅ {}:{} 条 → {}\n",
pids.join(" + "),
entries.len(),
path.display()
));
}
report.push_str(&format!(
"\n共 {} 条笔记,{} 条已归档,{} 个项目登记被跳过(修复登记路径后重跑即可)。",
rows.len(),
archived_notes,
skipped
));
Ok(report)
}
fn get_project_snapshots(project_id: &str, days: u32) -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
ensure_project_exists(&conn, project_id)?;
let mut stmt = conn
.prepare(
"SELECT snapshot_date, days_since_commit, blueprint_done_rate,
blueprint_in_progress, blueprint_blocked, has_errors
FROM project_snapshots
WHERE project_id = ?1
AND snapshot_date >= date('now', 'localtime', ?2)
ORDER BY snapshot_date ASC",
)
.map_err(|e| e.to_string())?;
let offset = format!("-{} days", days);
let rows: Vec<(String, Option<i64>, Option<f64>, i64, i64, i64)> = stmt
.query_map(rusqlite::params![project_id, offset], |r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok(format!(
"项目 `{}` 暂无历史快照(快照在每次应用启动时写入,最多保留 {} 天)。",
project_id, days
));
}
let mut md = format!(
"# 项目历史快照:`{}`\n\n最近 {} 天,共 {} 条记录\n\n",
project_id,
days,
rows.len()
);
md.push_str("| 日期 | 距上次提交(天) | 蓝图完成率 | 进行中模块 | 阻塞任务 | 有错误 |\n");
md.push_str("|------|--------------|-----------|-----------|---------|------|\n");
for (date, days_commit, done_rate, in_prog, blocked, has_err) in &rows {
let commit_col = days_commit
.map(|d| d.to_string())
.unwrap_or_else(|| "—".to_string());
let rate_col = done_rate
.map(|r| format!("{:.0}%", r * 100.0))
.unwrap_or_else(|| "—".to_string());
let err_col = if *has_err == 1 { "⚠️" } else { "✅" };
md.push_str(&format!(
"| {} | {} | {} | {} | {} | {} |\n",
date, commit_col, rate_col, in_prog, blocked, err_col
));
}
md.push_str(
"\n---\n\n请根据以上趋势数据,分析哪些变量与项目停滞或健康度下降最相关,并给出改进建议。\n",
);
Ok(md)
}
pub fn list_all_projects() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
let mut stmt = conn
.prepare(
"SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, pgm.group_id
FROM project_workspaces pw
JOIN project_profiles pp ON pp.id = pw.profile_id
LEFT JOIN project_group_map pgm ON pgm.project_id = pw.id
ORDER BY pp.name",
)
.map_err(|e| e.to_string())?;
let rows: Vec<(
String,
String,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
)> = stmt
.query_map([], |r| {
Ok((
r.get(0)?,
r.get(1)?,
r.get(2)?,
r.get(3)?,
r.get(4)?,
r.get(5)?,
))
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
if rows.is_empty() {
return Ok("炼境暂无登记项目。".to_string());
}
let mut out = format!("## 炼境全量项目(共 {} 个)\n\n", rows.len());
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)
})
} 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 {
"✅ 已注入"
} else {
"⚠️ 未注入"
};
let group_str = group_id.as_deref().unwrap_or("无产品组");
let path_str = project_path.as_deref().unwrap_or("未配置路径");
out.push_str(&format!(
"- **{}** `{}`\n {} | 组: `{}` | {}\n\n",
name, id, inject_icon, group_str, path_str
));
}
out.push_str("---\n对未注入项目调用 `inject_mcp(project_id)` 完成接入。");
Ok(out)
}
pub fn scan_unregistered_projects() -> Result<String, String> {
let conn = db::pool().get().map_err(|e| e.to_string())?;
// 取所有已登记的 win_path
let mut stmt = conn
.prepare("SELECT win_path FROM project_workspaces WHERE win_path IS NOT NULL")
.map_err(|e| e.to_string())?;
let registered_paths: std::collections::HashSet<String> = stmt
.query_map([], |r| r.get::<_, String>(0))
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.map(|p| p.trim_end_matches(['/', '\\']).to_string())
.collect();
// 推导父目录(去重)
let mut parent_dirs: std::collections::HashSet<std::path::PathBuf> =
std::collections::HashSet::new();
for p in &registered_paths {
if let Some(parent) = std::path::Path::new(p).parent() {
parent_dirs.insert(parent.to_path_buf());
}
}
if parent_dirs.is_empty() {
return Ok("炼境暂无登记项目,无法推导扫描目录。".to_string());
}
let mut unregistered: Vec<(String, bool, Vec<String>, bool)> = Vec::new();
for parent in &parent_dirs {
let entries = std::fs::read_dir(parent).map_err(|e| e.to_string())?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let path_str = path
.to_string_lossy()
.trim_end_matches(['/', '\\'])
.to_string();
if registered_paths.contains(&path_str) {
continue;
}
// 隐藏目录跳过
if path
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(false)
{
continue;
}
let has_git = path.join(".git").exists();
let mut stack: Vec<String> = Vec::new();
if path.join("package.json").exists() {
stack.push("JS/TS".into());
}
if path.join("Cargo.toml").exists() {
stack.push("Rust".into());
}
if path.join("go.mod").exists() {
stack.push("Go".into());
}
if path.join("pom.xml").exists() {
stack.push("Java".into());
}
let has_agents = path.join("AGENTS.md").exists();
unregistered.push((path_str, has_git, stack, has_agents));
}
}
if unregistered.is_empty() {
return Ok(format!(
"扫描了 {} 个父目录,所有子目录均已在炼境注册。",
parent_dirs.len()
));
}
unregistered.sort_by(|a, b| a.0.cmp(&b.0));
let mut out = format!(
"## 未注册目录(共 {} 个)\n\n扫描范围:{}\n\n",
unregistered.len(),
parent_dirs
.iter()
.map(|p| p.to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("、")
);
for (path, has_git, stack, has_agents) in &unregistered {
let git_icon = if *has_git { "🗂 git" } else { "❌ 无git" };
let agents_icon = if *has_agents { "📄 AGENTS" } else { "—" };
let stack_str = if stack.is_empty() {
"未知".to_string()
} else {
stack.join("/")
};
let name = std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.clone());
out.push_str(&format!(
"- **{}** | {} | {} | {}\n `{}`\n\n",
name, git_icon, stack_str, agents_icon, path
));
}
out.push_str(
"---\n在炼境 UI 添加项目后,调用 `apply_onboarding_pack(project_id)` 分发接入包。",
);
Ok(out)
}
// ── 测试 ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tools_list_contains_expected_tools() {
let result = tools_list_result();
let tools = result["tools"].as_array().expect("tools 应为数组");
let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect();
// 核心工具必须存在
assert!(
names.contains(&"apply_onboarding_pack"),
"缺少 apply_onboarding_pack"
);
assert!(
names.contains(&"verify_onboarding"),
"缺少 verify_onboarding"
);
assert!(
names.contains(&"list_all_projects"),
"缺少 list_all_projects"
);
assert!(
names.contains(&"scan_unregistered_projects"),
"缺少 scan_unregistered_projects"
);
assert!(names.contains(&"inject_mcp"), "缺少 inject_mcp");
assert!(
names.contains(&"create_pull_request"),
"缺少 create_pull_request"
);
assert!(
names.contains(&"get_delivery_status"),
"缺少 get_delivery_status"
);
assert!(
names.contains(&"get_toolchain_status"),
"缺少 get_toolchain_status"
);
assert!(
names.contains(&"prepare_toolchain"),
"缺少 prepare_toolchain"
);
assert!(names.contains(&"poll_now"), "缺少 poll_now");
assert!(
names.contains(&"predict_task_risk"),
"缺少 predict_task_risk"
);
assert!(
names.contains(&"ingest_git_history"),
"缺少 ingest_git_history"
);
assert!(
names.contains(&"unregister_project"),
"缺少 unregister_project"
);
assert!(names.contains(&"setup_gitea_repo"), "缺少 setup_gitea_repo");
assert!(
names.contains(&"plan_cicd_workflow"),
"缺少 plan_cicd_workflow"
);
assert!(
names.contains(&"apply_cicd_workflow"),
"缺少 apply_cicd_workflow"
);
// 每个工具都有 description 和 inputSchema
for tool in tools {
let name = tool["name"].as_str().unwrap_or("?");
assert!(
tool["description"].is_string(),
"工具 {name} 缺少 description"
);
assert!(
tool["inputSchema"].is_object(),
"工具 {name} 缺少 inputSchema"
);
assert!(
tool["annotations"].is_object(),
"工具 {name} 缺少风险 annotations"
);
}
}
#[test]
fn core_status_tools_are_read_only_and_destructive_tools_are_marked() {
let result = tools_list_result();
let tools = result["tools"].as_array().unwrap();
for name in [
"get_diagnostics",
"get_delivery_status",
"get_toolchain_status",
"verify_onboarding",
] {
let tool = tools.iter().find(|tool| tool["name"] == name).unwrap();
assert_eq!(tool["annotations"]["readOnlyHint"], true, "{name}");
assert_eq!(tool["annotations"]["destructiveHint"], false, "{name}");
assert_eq!(tool["annotations"]["idempotentHint"], true, "{name}");
}
let delivery = tools
.iter()
.find(|tool| tool["name"] == "get_delivery_status")
.unwrap();
assert_eq!(delivery["annotations"]["openWorldHint"], true);
for name in ["merge_pull_request", "unregister_project"] {
let tool = tools.iter().find(|tool| tool["name"] == name).unwrap();
assert_eq!(tool["annotations"]["destructiveHint"], true, "{name}");
assert_eq!(tool["annotations"]["readOnlyHint"], false, "{name}");
}
}
#[test]
fn core_status_response_contains_summary_and_structured_content() {
let response = tool_success_response(
"get_delivery_status",
json!({
"project_id": "p1",
"git": { "state": "clean" },
"pull_request": { "state": "open" },
"ci": { "state": "passed" },
"review": { "state": "approved" },
"deployment": { "state": "unknown" }
})
.to_string(),
);
assert_eq!(response["structuredContent"]["project_id"], "p1");
let text = response["content"][0]["text"].as_str().unwrap();
assert!(text.contains("Git=clean"));
assert!(text.contains("CI=passed"));
}
#[test]
fn scoped_catalog_binds_project_id_and_hides_global_management() {
let result = tools_list_result_for_project("p1");
let tools = result["tools"].as_array().unwrap();
let delivery = tools
.iter()
.find(|tool| tool["name"] == "get_delivery_status")
.unwrap();
assert_eq!(
delivery["inputSchema"]["properties"]["project_id"]["const"],
"p1"
);
assert!(!delivery["inputSchema"]["required"]
.as_array()
.unwrap()
.iter()
.any(|field| field == "project_id"));
assert!(!tools
.iter()
.any(|tool| tool["name"] == "unregister_project"));
assert!(!tools.iter().any(|tool| tool["name"] == "list_all_projects"));
}
#[tokio::test]
async fn scoped_calls_reject_cross_project_and_global_write_before_io() {
let mismatch = tools_call_for_project(
Some(&json!({
"name": "get_delivery_status",
"arguments": { "project_id": "p2" }
})),
"p1",
)
.await;
assert_eq!(mismatch["isError"], true);
assert_eq!(mismatch["structuredContent"]["code"], "scope_violation");
assert!(mismatch["content"][0]["text"]
.as_str()
.unwrap()
.contains("绑定 project_id=p1"));
let global_write = tools_call_for_project(
Some(&json!({
"name": "unregister_project",
"arguments": { "project_id": "p1" }
})),
"p1",
)
.await;
assert_eq!(global_write["isError"], true);
assert_eq!(global_write["structuredContent"]["code"], "scope_violation");
}
#[test]
fn scoped_call_injects_omitted_project_id() {
let mut args = json!({});
bind_project_scope("get_delivery_status", &mut args, "p1").unwrap();
assert_eq!(args["project_id"], "p1");
}
#[test]
fn merge_pull_request_schema_requires_confirmation_and_expected_sha() {
let result = tools_list_result();
let tools = result["tools"].as_array().expect("tools 应为数组");
let merge = tools
.iter()
.find(|tool| tool["name"] == "merge_pull_request")
.expect("缺少 merge_pull_request");
let required: Vec<&str> = merge["inputSchema"]["required"]
.as_array()
.expect("required 应为数组")
.iter()
.filter_map(|value| value.as_str())
.collect();
assert!(required.contains(&"project_id"));
assert!(required.contains(&"pr_number"));
assert!(required.contains(&"expected_head_sha"));
assert!(required.contains(&"confirmed"));
}
#[tokio::test]
async fn merge_pull_request_rejects_missing_confirmation_before_external_io() {
let result = tools_call(Some(&json!({
"name": "merge_pull_request",
"arguments": {
"project_id": "project-1",
"pr_number": 13,
"expected_head_sha": "0123456789abcdef0123456789abcdef01234567"
}
})))
.await;
assert_eq!(result["isError"], true);
assert!(result["content"][0]["text"]
.as_str()
.unwrap_or_default()
.contains("明确确认"));
}
#[tokio::test]
async fn delivery_status_tool_requires_project_id() {
let result = tools_call(Some(&json!({
"name": "get_delivery_status",
"arguments": {}
})))
.await;
assert_eq!(result["isError"], true);
assert!(result["content"][0]["text"]
.as_str()
.unwrap_or_default()
.contains("project_id 不能为空"));
}
#[tokio::test]
async fn toolchain_status_tool_requires_project_id() {
let result = tools_call(Some(&json!({
"name": "get_toolchain_status",
"arguments": {}
})))
.await;
assert_eq!(result["isError"], true);
assert!(result["content"][0]["text"]
.as_str()
.unwrap_or_default()
.contains("project_id 不能为空"));
}
#[test]
fn cicd_workflow_schemas_require_safe_confirmation_contract() {
let result = tools_list_result();
let tools = result["tools"].as_array().unwrap();
let plan = tools
.iter()
.find(|tool| tool["name"] == "plan_cicd_workflow")
.expect("缺少 plan_cicd_workflow");
let apply = tools
.iter()
.find(|tool| tool["name"] == "apply_cicd_workflow")
.expect("缺少 apply_cicd_workflow");
let required = apply["inputSchema"]["required"].as_array().unwrap();
for field in [
"project_id",
"workflow_type",
"expected_digest",
"confirmed",
] {
assert!(required.iter().any(|value| value == field));
}
for (name, tool) in [("plan", plan), ("apply", apply)] {
let properties = tool["inputSchema"]["properties"].as_object().unwrap();
for forbidden in ["secret", "command", "path", "url", "host"] {
assert!(
!properties.contains_key(forbidden),
"{name} schema 不应接收 {forbidden}"
);
}
}
}
#[tokio::test]
async fn apply_cicd_workflow_rejects_missing_confirmation_before_db_io() {
let result = tools_call(Some(&json!({
"name": "apply_cicd_workflow",
"arguments": {
"project_id": "project-1",
"workflow_type": "pr_ci",
"expected_digest": "0123456789abcdef"
}
})))
.await;
assert_eq!(result["isError"], true);
assert!(result["content"][0]["text"]
.as_str()
.unwrap_or_default()
.contains("明确确认"));
}
#[tokio::test]
async fn plan_cicd_workflow_requires_project_id_before_db_io() {
let result = tools_call(Some(&json!({
"name": "plan_cicd_workflow",
"arguments": { "workflow_type": "pr_ci" }
})))
.await;
assert_eq!(result["isError"], true);
assert!(result["content"][0]["text"]
.as_str()
.unwrap_or_default()
.contains("project_id 不能为空"));
}
#[test]
fn prepare_toolchain_schema_requires_confirmation() {
let result = tools_list_result();
let tool = result["tools"]
.as_array()
.unwrap()
.iter()
.find(|tool| tool["name"] == "prepare_toolchain")
.unwrap();
let required = tool["inputSchema"]["required"].as_array().unwrap();
assert!(required.iter().any(|value| value == "project_id"));
assert!(required.iter().any(|value| value == "confirmed"));
}
/// 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_backfill_merges_duplicate_registrations_same_path() {
// 同路径重复登记(mcp-single-server 已知遗留):两条 project 记录指向同一目录,
// 回填必须归并写入而非互相覆盖(2026-09-11 真机实锤丢 20 条的回归)
let conn = rusqlite::Connection::open_in_memory().unwrap();
setup_notes_schema(&conn);
let base = temp_base("duppath");
for pid in ["uuid-form", "name-form"] {
conn.execute(
"INSERT INTO project_workspaces (id, win_path) VALUES (?1, ?2)",
rusqlite::params![pid, base.to_str().unwrap()],
)
.unwrap();
}
for i in 0..3 {
conn.execute(
"INSERT INTO project_notes (project_id, content) VALUES ('uuid-form', ?1)",
[format!("a-{i}")],
)
.unwrap();
}
conn.execute(
"INSERT INTO project_notes (project_id, content) VALUES ('name-form', 'b-0')",
[],
)
.unwrap();
let report = backfill_notes_archive(&conn).expect("回填应成功");
let file = base.join(".blueprint").join("notes-archive.jsonl");
let text = std::fs::read_to_string(&file).unwrap();
assert_eq!(
text.lines().count(),
4,
"同路径两登记的笔记应全部归并: {report}"
);
assert!(
report.contains("4 条已归档"),
"报告应按笔记数计数: {report}"
);
}
#[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();
let tools = result["tools"].as_array().unwrap();
// verify 必填 project_id;apply 自 U1 起可省略(批量模式)
let verify = tools
.iter()
.find(|t| t["name"] == "verify_onboarding")
.unwrap();
let fields: Vec<&str> = verify["inputSchema"]["required"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(
fields.contains(&"project_id"),
"verify_onboarding 应必填 project_id"
);
let apply = tools
.iter()
.find(|t| t["name"] == "apply_onboarding_pack")
.unwrap();
assert!(
apply["inputSchema"]["required"]
.as_array()
.unwrap()
.is_empty(),
"apply_onboarding_pack 的 project_id 应可省略(U1 批量模式)"
);
}
}