dev-manager-tauri/src-tauri/src/mcp_tools.rs
lanrtop 13aa3a1a8c
All checks were successful
Push & PR Check / check (push) Successful in 1m2s
Push & PR Check / check (pull_request) Successful in 59s
feat(onboarding/parity): 接入包改写托管块前检测块外未提交改动,给出混合文件预警(P10)
2026-10-01 enterprise 的 git-workflow.md 在有项目未提交改写时被升级了托管块,
同一文件混有炼境与项目两种来源的改动,按「禁止字符串手术」无法拆分提交,只能交人定案。
管家应在制造混合文件时明确告知,而不是事后由人发现。

- TemplateResult 新增可选 warning(序列化时缺省省略,前端类型同步)
- apply_onboarding_pack 在模板分发与 buff 蓝图规则同步前后夹一层检测:写入前对
  AGENTS.md、docs/ai-context/git-workflow.md、CLAUDE.md 跑 git status,有改动再取
  HEAD:./<文件>(不在 HEAD 中按空内容算),两边去掉 ONBOARDING_MANAGED 与
  BLUEPRINT_MANAGED 块后比较(忽略行尾与空行);写入后只对本次确被改写的文件预警,
  文案含文件名与「块外有未提交改动,升级后将成为混合文件」
- 只提示不阻断,块外内容照旧不动;git 不可用、非仓库或命令失败一律静默;
  git status 带 --no-optional-locks,不抢 index.lock
- CLAUDE.md 只由蓝图规则同步改写,不在模板结果里,预警时补一条结果
- BLUEPRINT_MANAGED 标记提为 blueprint.rs 的 pub(crate) 常量,检测与 upsert 共用
- MCP 单项目输出在该文件行下加预警行,批量输出追加混合文件清单;工具描述同步;
  炼境 UI 接入包结果行下显示预警
- 契约文档 project-context.yaml 同步;蓝图 P10 标 ✅ 并记录验证与已知限制

验证:cargo test --locked lib 270 passed / 5 ignored;新增 3 个测试(纯函数、临时 git
仓库三种情形、非 git 目录),变异自检(探测强制返回 true 时两个场景测试均失败);
pnpm typecheck 与 pnpm test 通过。

Rules-Applied: R01,R06,R12
Feature-Confirmed: true

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 03:58:28 +09:00

2858 lines
128 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": "列出子项目通过「炼境使者」回传、尚未处置的洞察(带编号 #note_id、来源项目、客户端),并报告已处置条数;超出展示上限时明确说明未展示条数。炼境会话启动时调用,检查是否有需要处置的治理反馈;处置用 resolve_envoy_return。",
"inputSchema": {
"type": "object",
"properties": {
"include_resolved": { "type": "boolean", "description": "为 true 时附列已处置条目及其处置方式、落点与理由(审计/更正用),默认 false" }
}
}
},
{
"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_resource_status",
"description": "只读查询项目 Gitea PR 与 webhook readiness;仅返回状态和来源,不返回凭证、URL 或连接参数。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID" }
},
"required": ["project_id"]
}
},
{
"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 存在即跳过,块外用户内容绝不触碰;目标文件在 git 中块外已有未提交改动时仍只改托管块,但对该文件给出「混合文件」预警,提交前须先确认块外改动归属),并挂载 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"]
}
}
]
});
if let Some(tools) = result.get_mut("tools").and_then(Value::as_array_mut) {
let after_list = tools
.iter()
.position(|tool| tool["name"] == "list_envoy_returns")
.map_or(tools.len(), |index| index + 1);
if let Value::Array(envoy_tools) = envoy_tool_schemas() {
tools.splice(after_list..after_list, envoy_tools);
}
}
apply_tool_annotations(&mut result);
result
}
/// 使者回传两件工具(单独成函数:并入主清单会超出 json! 宏递归上限)
fn envoy_tool_schemas() -> Value {
json!([
{
"name": "report_envoy_return",
"description": "炼境使者回传:把本会话遇到的体系级摩擦或经验证的实践回传炼境(Claude Code、Codex、DSH 通用入口)。只追加一条信号、不改任何代码或配置,不必逐条征得用户同意,回传后在总结里列出返回的编号。直接回传:炼境分发的模板/约定/skill/lefthook/MCP 工具出错或碍事(即使只在本项目复现,scope 填 this_project);用户要求回传。经验与假设类须非显而易见且跨项目成立,常识与只在本项目特定组合下成立的 corner case 不回传。每条洞察单独调用一次,同一会话不要换说法重报;断连重试同内容不会重复写入。不得包含凭据、密钥、账号、内网地址或生产数据(内容会写进本项目 .blueprint/notes-archive.jsonl 随项目进 Git)。",
"inputSchema": {
"type": "object",
"properties": {
"project_id": { "type": "string", "description": "项目 workspace ID(.lian-jing/context.json 中的 project_id)" },
"kind": { "type": "string", "enum": ["template_bug", "validated_practice", "workflow_hypothesis"], "description": "template_bug=炼境模板/约定/工具有缺陷;validated_practice=有证据的有效实践;workflow_hypothesis=待验证的体系改进假设" },
"confidence": { "type": "string", "enum": ["confirmed", "under_validation"], "description": "confirmed 须能陈述至少一条具体代价或验证结果;否则一律 under_validation" },
"scope": { "type": "string", "description": "影响范围:this_project | all_projects | project_type:<类型>" },
"content": { "type": "string", "description": "一句话写清摩擦/实践及证据(换行会被压成单行,最多 1000 字)" },
"suggestion": { "type": "string", "enum": ["fix_template", "update_convention", "log_for_review"], "description": "建议炼境:fix_template=改接入包模板;update_convention=改 CONVENTIONS;log_for_review=记录待观察" },
"client": { "type": "string", "enum": ["claude", "codex", "dsh", "other"], "description": "当前执行回传的 agent 客户端:claude=Claude Code,codex=Codex,dsh=DeepSeek Harness,其他填 other" }
},
"required": ["project_id", "kind", "confidence", "scope", "content", "suggestion", "client"]
}
},
{
"name": "resolve_envoy_return",
"description": "处置一条使者回传并留回执,处置后不再出现在 list_envoy_returns 默认列表。可重复调用更正(覆盖原回执)。只能处置以「【炼境使者回传】」开头的笔记。仅供炼境控制面会话使用:子项目 agent 只回传、不处置。",
"inputSchema": {
"type": "object",
"properties": {
"note_id": { "type": "integer", "description": "回传编号(list_envoy_returns 中的 #note_id)" },
"disposition": { "type": "string", "enum": ["landed", "carded", "watching", "obsolete", "rejected"], "description": "landed=已落地(有落点证据);carded=已转任务卡;watching=待观察/仅归档;obsolete=已过时;rejected=不由炼境处理" },
"reference": { "type": "string", "description": "落点:提交 SHA、文件路径或任务卡标题;landed/carded 必填" },
"reason": { "type": "string", "description": "处置依据(一句话,必填)" }
},
"required": ["note_id", "disposition", "reason"]
}
}
])
}
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);
tools.extend([
json!({
"name": "register_project_dev_variable",
"description": "登记本项目开发阶段所需变量的名称和用途;不提交值。请让用户在炼境项目 UI 填写非生产、低权限、可重置的值。",
"inputSchema": {"type":"object","properties":{
"project_id":{"type":"string"},
"name":{"type":"string","description":"变量名,例如 E2E_PASSWORD"},
"purpose":{"type":"string","description":"不含秘密的用途说明"}
},"required":["name","purpose"]},
"annotations":{"readOnlyHint":false,"destructiveHint":false}
}),
json!({
"name": "register_project_test_account",
"description": "仅登记本项目非生产测试登录账户的需求卡,不提交账号或密码。重复相同名称和语义返回同一张卡;用户随后在炼境项目 UI 一次填写。",
"inputSchema": {"type":"object","properties":{
"project_id":{"type":"string"},
"name":{"type":"string","description":"稳定需求名,例如 E2E_LOGIN"},
"service":{"type":"string","description":"目标系统名称,不含账号或密码"},
"environment":{"type":"string","description":"非生产测试环境名称"},
"purpose":{"type":"string","description":"本账户用于什么测试,不含秘密"}
},"required":["name","service","environment","purpose"]},
"annotations":{"readOnlyHint":false,"destructiveHint":false}
}),
json!({
"name": "list_project_dev_variables",
"description": "测试登录前先查询本项目开发需求状态。返回名称、类型、目标系统、环境、用途和待填写/已配置状态,不返回值或凭证引用;已有需求请复用,勿重复登记。",
"inputSchema": {"type":"object","properties":{"project_id":{"type":"string"}},"required":[]},
"annotations":{"readOnlyHint":true}
}),
json!({
"name": "read_project_dev_variable",
"description": "经本机认证后单项读取本项目已由用户填写的标量开发变量;测试账户请使用 read_project_test_account。返回值会进入 Agent 会话;仅用于非生产、低权限、可重置的开发数据。",
"inputSchema": {"type":"object","properties":{
"project_id":{"type":"string"},
"name":{"type":"string"}
},"required":["name"]},
"annotations":{"readOnlyHint":true}
}),
json!({
"name": "read_project_test_account",
"description": "经本机认证后读取本项目已由用户填写的单张非生产测试账户卡,返回 username/password 给 Agent 用于当前测试。不要写入仓库、日志或普通聊天摘要。",
"inputSchema": {"type":"object","properties":{
"project_id":{"type":"string"},
"name":{"type":"string","description":"已登记的测试账户需求名"}
},"required":["name"]},
"annotations":{"readOnlyHint":true}
}),
]);
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"
| "resolve_envoy_return"
);
let idempotent = read_only
|| matches!(
name,
"inject_mcp"
| "apply_onboarding_pack"
| "setup_webhook"
| "sync_blueprint_rules"
| "backfill_notes_archive"
| "ingest_git_history"
| "report_envoy_return"
| "resolve_envoy_return"
);
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)
),
"get_project_resource_status" => format!(
"项目资源:project_id={},Gitea PR={},webhook={}",
value["project_id"].as_str().unwrap_or("?"),
value["gitea_pr"]["state"].as_str().unwrap_or("unknown"),
value["gitea_webhook"]["state"]
.as_str()
.unwrap_or("unknown")
),
"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",
"get_project_resource_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 project_scope.is_none()
&& matches!(
tool_name.as_str(),
"register_project_dev_variable"
| "register_project_test_account"
| "list_project_dev_variables"
| "read_project_dev_variable"
| "read_project_test_account"
)
{
return scope_violation("开发变量仅允许项目级 MCP 连接");
}
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() {
"register_project_dev_variable" => {
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
let name = args.get("name").and_then(Value::as_str).unwrap_or("");
let purpose = args.get("purpose").and_then(Value::as_str).unwrap_or("");
let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?;
crate::commands::dev_variables::register(&conn, project_id, name, purpose)?;
Ok("已登记开发变量需求;等待用户在炼境项目 UI 填写".to_string())
}
"register_project_test_account" => {
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
let name = args.get("name").and_then(Value::as_str).unwrap_or("");
let service = args.get("service").and_then(Value::as_str).unwrap_or("");
let environment = args.get("environment").and_then(Value::as_str).unwrap_or("");
let purpose = args.get("purpose").and_then(Value::as_str).unwrap_or("");
let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?;
crate::commands::dev_variables::register_test_account(
&conn, project_id, name, service, environment, purpose,
)?;
Ok("已登记测试账户需求;等待用户在炼境项目 UI 一次填写账号与密码".to_string())
}
"list_project_dev_variables" => {
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?;
let statuses = crate::commands::dev_variables::list(
&conn,
&crate::credential_store::WindowsCredentialStore,
project_id,
)?;
serde_json::to_string(&statuses).map_err(|_| "序列化开发变量状态失败".to_string())
}
"read_project_dev_variable" => {
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
let name = args.get("name").and_then(Value::as_str).unwrap_or("");
let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?;
crate::commands::dev_variables::read(
&conn,
&crate::credential_store::WindowsCredentialStore,
project_id,
name,
)
}
"read_project_test_account" => {
let project_id = args.get("project_id").and_then(Value::as_str).unwrap_or("");
let name = args.get("name").and_then(Value::as_str).unwrap_or("");
let conn = db::pool().get().map_err(|_| "数据库不可用".to_string())?;
let account = crate::commands::dev_variables::read_test_account(
&conn,
&crate::credential_store::WindowsCredentialStore,
project_id,
name,
)?;
serde_json::to_string(&account).map_err(|_| "序列化测试账户失败".to_string())
}
"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" => {
let include_resolved = args.get("include_resolved").is_some_and(|value| {
value.as_bool() == Some(true) || value.as_str() == Some("true")
});
let conn = db::pool().get().map_err(|e| e.to_string())?;
crate::envoy::all_returns(&conn)
.map(|returns| crate::envoy::format_returns(&returns, include_resolved))
}
"report_envoy_return" => {
let conn = db::pool().get().map_err(|e| e.to_string())?;
report_envoy_return(&conn, &args)
}
"resolve_envoy_return" => {
let conn = db::pool().get().map_err(|e| e.to_string())?;
resolve_envoy_return(&conn, &args)
}
"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)
}
"get_project_resource_status" => {
let pid = args.get("project_id").and_then(|v| v.as_str()).unwrap_or("");
crate::commands::gitea::get_project_resource_status(pid.to_string())
.and_then(|status| serde_json::to_string(&status).map_err(|_| "项目资源状态序列化失败".to_string()))
}
"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();
let mixed: Vec<&str> = report
.templates
.iter()
.filter(|t| t.warning.is_some())
.map(|t| t.name.as_str())
.collect();
let mixed_note = if mixed.is_empty() {
String::new()
} else {
format!(
" | ⚠️ 混合文件(块外有未提交改动): {}",
mixed.join(", ")
)
};
lines.push(format!(
"- {}: {} | hook={}{}",
id,
summary.join(", "),
report.hook.status,
mixed_note
));
}
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",
};
match &t.warning {
Some(warning) => {
format!(" {icon}: {}\n ⚠️ {warning}", t.name)
}
None => 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'
AND json_valid(context)
AND json_extract(context, '$.project_id') IS NOT NULL
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 条使者回传**——环的回流段在这些项目上是断的,考虑在该项目会话里回传一次(Claude 跑 `/envoy`,Codex/DSH 调 `report_envoy_return`):\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)
}
/// MCP 薄包装:参数提取 + 写库(envoy 模块)+ 双写 notes 档案(与 append_project_note 同一路径)
fn report_envoy_return(conn: &rusqlite::Connection, args: &Value) -> Result<String, String> {
let field = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("");
let project_id = field("project_id");
let input = crate::envoy::EnvoyReport {
kind: field("kind"),
confidence: field("confidence"),
scope: field("scope"),
content: field("content"),
suggestion: field("suggestion"),
client: field("client"),
};
let outcome = crate::envoy::report(conn, project_id, &input)?;
if outcome.duplicate {
return Ok(format!(
"同内容回传已存在(#{},待炼境处置),未重复写入",
outcome.note_id
));
}
let archive_note = match archive_note_append(conn, outcome.note_id) {
Ok(()) => "已同步项目 .blueprint/notes-archive.jsonl".to_string(),
Err(e) => {
db::log_event(
"mcp_tools",
"warn",
&format!("使者回传档案双写失败(DB 已保存,backfill_notes_archive 可自愈): {e}"),
None,
);
"项目档案双写失败已记日志,可调 backfill_notes_archive 自愈".to_string()
}
};
Ok(format!(
"已回传炼境 ✅ 编号 #{}(客户端 {};{})。炼境会在控制面处置并留回执,子项目无需跟进。",
outcome.note_id, input.client, archive_note
))
}
fn resolve_envoy_return(conn: &rusqlite::Connection, args: &Value) -> Result<String, String> {
// agent 常把编号写成 "#123"、"123" 或 123.0,统一宽容解析
let raw = args.get("note_id");
let note_id = raw
.and_then(|value| {
value
.as_i64()
.or_else(|| {
value
.as_f64()
.filter(|f| f.fract() == 0.0)
.map(|f| f as i64)
})
.or_else(|| {
value
.as_str()
.and_then(|s| s.trim().trim_start_matches('#').parse().ok())
})
})
.ok_or_else(|| match raw {
None => "note_id 必填(list_envoy_returns 中的 #编号)".to_string(),
Some(value) => format!("note_id 无法解析为编号:{value}"),
})?;
let field = |key: &str| args.get(key).and_then(Value::as_str).unwrap_or("");
let disposition = field("disposition");
let previous = crate::envoy::resolve(
conn,
note_id,
disposition,
field("reference"),
field("reason"),
)?;
Ok(match previous {
Some(previous) => format!("已更正回传 #{note_id} 的处置:{previous} → {disposition} ✅"),
None => format!("已处置回传 #{note_id} → {disposition} ✅"),
})
}
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();
crate::write_if_content_changed(&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"));
let readiness = tools
.iter()
.find(|tool| tool["name"] == "get_project_resource_status")
.unwrap();
assert_eq!(
readiness["inputSchema"]["properties"]["project_id"]["const"],
"p1"
);
for forbidden in ["get_secret", "ssh_exec", "run_sql", "http_proxy", "deploy"] {
assert!(!tools.iter().any(|tool| tool["name"] == forbidden));
}
}
#[tokio::test]
async fn scoped_calls_reject_cross_project_and_global_write_before_io() {
let readiness_mismatch = tools_call_for_project(
Some(&json!({"name":"get_project_resource_status","arguments":{"project_id":"p2"}})),
"p1",
)
.await;
assert_eq!(
readiness_mismatch["structuredContent"]["code"],
"scope_violation"
);
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 envoy_tools_split_between_project_scope_and_control_plane() {
let scoped = tools_list_result_for_project("p1");
let scoped_names: Vec<&str> = scoped["tools"]
.as_array()
.unwrap()
.iter()
.filter_map(|tool| tool["name"].as_str())
.collect();
assert!(
scoped_names.contains(&"report_envoy_return"),
"三端在项目地址上必须能回传"
);
assert!(
!scoped_names.contains(&"resolve_envoy_return"),
"处置只属于控制面"
);
assert!(!scoped_names.contains(&"list_envoy_returns"));
let global = tools_list_result();
let annotations = |name: &str| {
global["tools"]
.as_array()
.unwrap()
.iter()
.find(|tool| tool["name"] == name)
.unwrap_or_else(|| panic!("缺少 {name}"))["annotations"]
.clone()
};
assert_eq!(annotations("report_envoy_return")["readOnlyHint"], false);
assert_eq!(annotations("report_envoy_return")["destructiveHint"], false);
assert_eq!(annotations("resolve_envoy_return")["readOnlyHint"], false);
assert_eq!(
annotations("resolve_envoy_return")["destructiveHint"],
true,
"upsert 会覆盖旧回执"
);
assert_eq!(annotations("list_envoy_returns")["readOnlyHint"], true);
}
#[tokio::test]
async fn envoy_return_round_trip_over_dispatch() {
db::init_test_pool();
let base = temp_base("envoy_dispatch");
{
let conn = db::pool().get().unwrap();
conn.execute(
"INSERT OR IGNORE INTO project_profiles (id, name) VALUES ('envoy-fixture-profile', 'Envoy fixture')",
[],
)
.unwrap();
conn.execute(
"INSERT OR IGNORE INTO project_workspaces (id, profile_id, win_path) VALUES ('envoy-fixture', 'envoy-fixture-profile', ?1)",
[base.to_str().unwrap()],
)
.unwrap();
}
let text_of = |value: &Value| {
value["content"][0]["text"]
.as_str()
.unwrap_or_default()
.to_string()
};
// 项目地址:project_id 由连接绑定,agent 无需显式传
let reported = tools_call_for_project(
Some(&json!({"name": "report_envoy_return", "arguments": {
"kind": "template_bug", "confidence": "under_validation", "scope": "all_projects",
"content": "dispatch 往返测试", "suggestion": "log_for_review", "client": "dsh"
}})),
"envoy-fixture",
)
.await;
assert_ne!(reported["isError"], true, "{reported}");
let reported_text = text_of(&reported);
let note_id: i64 = reported_text
.split('#')
.nth(1)
.and_then(|rest| rest.split(|c: char| !c.is_ascii_digit()).next())
.and_then(|digits| digits.parse().ok())
.unwrap_or_else(|| panic!("回传结果应带编号: {reported_text}"));
let archive =
std::fs::read_to_string(base.join(".blueprint").join("notes-archive.jsonl")).unwrap();
assert!(
archive.contains("客户端: dsh"),
"须与 append_project_note 同样双写档案"
);
let listed = text_of(
&tools_call(Some(
&json!({"name": "list_envoy_returns", "arguments": {}}),
))
.await,
);
assert!(
listed.contains(&format!("**#{note_id}** | **来源**: Envoy fixture")),
"{listed}"
);
assert!(listed.contains("客户端: dsh"));
let scoped_resolve = tools_call_for_project(
Some(&json!({"name": "resolve_envoy_return", "arguments": {
"note_id": note_id, "disposition": "rejected", "reason": "x"
}})),
"envoy-fixture",
)
.await;
assert_eq!(
scoped_resolve["structuredContent"]["code"],
"scope_violation"
);
let resolved = tools_call(Some(&json!({"name": "resolve_envoy_return", "arguments": {
"note_id": format!("#{note_id}"), "disposition": "rejected", "reason": "探针测试"
}})))
.await;
assert!(
text_of(&resolved).contains(&format!("已处置回传 #{note_id} → rejected")),
"{resolved}"
);
let listed_after = text_of(
&tools_call(Some(
&json!({"name": "list_envoy_returns", "arguments": {}}),
))
.await,
);
assert!(
!listed_after.contains(&format!("**#{note_id}**")),
"处置后不得再列出"
);
let audit = text_of(
&tools_call(Some(
&json!({"name": "list_envoy_returns", "arguments": {"include_resolved": "true"}}),
))
.await,
);
assert!(audit.contains(&format!("**#{note_id}** Envoy fixture")));
}
#[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 批量模式)"
);
}
}