同路径重复登记(dev-manager-tauri 与 2c5b54ef 并存)暴露的三处问题: - remove_project_files_mcp 按路径解析目录后无条件删 Codex 管理块与 settings 条目, 删 UUID 那条会连带切断规范 ID 的 Codex 接入;改为只删 URL 属于本 project_id 或不是炼境项目路由(旧格式)的条目 - silent_projects 的「ID 或路径」任一匹配让一行吃到多条登记的并集(真机 147 / 86), 改为每条 commit 先按直接 ID、再按 buff 路径归属唯一 workspace - get_diagnostics 注入状态只打 ID,enterprise-system 的 UUID 被误读成炼境重复登记; 改为「名称 (`id`)」,已删除的登记标注未登记 Rules-Applied: R06,R07 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3208 lines
143 KiB
Rust
3208 lines
143 KiB
Rust
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 版本探针。PR/CI/review 默认按项目主工作区当前分支与本地 HEAD 查询;在独立 worktree 分支上交付时传 pr_number(优先)或 branch,改由 Gitea 给出 head 分支与 head SHA,结论绑定该 SHA,返回的 target 字段标明实际查询目标。仅当部署成功、探针健康且运行 SHA 与 Gitea 默认分支目标 SHA 完全相同才返回 deployed;失败、跳过部署、部署成功后的漂移、未配置或来源不可达分别保留 failed/skipped/drifted/not_configured/unavailable,不替项目作发布决定。",
|
||
"inputSchema": {
|
||
"type": "object",
|
||
"properties": {
|
||
"project_id": { "type": "string", "description": "项目 workspace ID(来自 list_all_projects 的返回结果)" },
|
||
"pr_number": { "type": "integer", "minimum": 1, "description": "可选,优先于 branch:按该 PR 查询。经 Gitea 读取该 PR 的 head 分支与 head SHA,CI 与 review 均绑定该 PR;合并前核对 PR 编号与 head SHA 时使用。" },
|
||
"branch": { "type": "string", "description": "可选:按该远端分支查询。有开放 PR 时以其 head SHA 为准,没有时取 Gitea 上该分支的 head SHA 查询 CI。不传 pr_number/branch 时沿用项目主工作区当前分支。" }
|
||
},
|
||
"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
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// inject_mcp 的回执按三端实际结果逐行给出(P8b):不再笼统宣称「已配置」。
|
||
/// Codex 托管块写入失败沿用旧语义整体报错;Claude 与 DSH 的未就绪状态如实列出但不报错。
|
||
fn render_inject_report(
|
||
project_id: &str,
|
||
reports: &[crate::onboard::clients::ClientReport],
|
||
) -> Result<String, String> {
|
||
use crate::onboard::clients::{ClientId, ClientOutcome};
|
||
let lines: Vec<String> = reports
|
||
.iter()
|
||
.map(|report| {
|
||
let name = match report.client {
|
||
ClientId::Claude => "Claude Code",
|
||
ClientId::Codex => "Codex",
|
||
ClientId::Dsh => "DSH",
|
||
};
|
||
format!("- {name}:{}", report.outcome.describe())
|
||
})
|
||
.collect();
|
||
let all_settled = reports.iter().all(|report| report.outcome.is_settled());
|
||
let text = format!(
|
||
"MCP 注入{}:project_id={project_id}(单一 lian-jing 服务器,项目专用地址)\n{}\nCodex 只在该目录受信任后才加载项目配置;已打开的会话需重开才生效。",
|
||
if all_settled { "完成" } else { "部分完成" },
|
||
lines.join("\n")
|
||
);
|
||
let codex_failed = reports.iter().any(|report| {
|
||
report.client == ClientId::Codex && matches!(report.outcome, ClientOutcome::Failed { .. })
|
||
});
|
||
if codex_failed {
|
||
Err(text)
|
||
} else {
|
||
Ok(text)
|
||
}
|
||
}
|
||
|
||
/// 只做 JSON 类型解析;取值语义(正整数、分支名格式)由能力本体 `DeliveryQuery::new` 单点校验。
|
||
/// 缺省或 null 视为未指定;显式给出但类型不对时报错,不静默回退到主工作区当前分支。
|
||
fn parse_delivery_query(
|
||
args: &Value,
|
||
) -> Result<crate::commands::delivery_status::DeliveryQuery, String> {
|
||
let pr_number = match args.get("pr_number") {
|
||
None | Some(Value::Null) => None,
|
||
Some(value) => Some(
|
||
value
|
||
.as_i64()
|
||
.ok_or_else(|| "pr_number 必须为正整数".to_string())?,
|
||
),
|
||
};
|
||
let branch = match args.get("branch") {
|
||
None | Some(Value::Null) => None,
|
||
Some(value) => Some(
|
||
value
|
||
.as_str()
|
||
.ok_or_else(|| "branch 必须为字符串".to_string())?,
|
||
),
|
||
};
|
||
crate::commands::delivery_status::DeliveryQuery::new(pr_number, branch)
|
||
}
|
||
|
||
fn delivery_target_summary(target: &Value) -> String {
|
||
let head = target["head_sha"].as_str().unwrap_or("unknown");
|
||
match target["mode"].as_str() {
|
||
Some("pull_request") => format!(
|
||
"PR #{}(head 分支 {},head SHA {head})",
|
||
target["pr_number"]
|
||
.as_i64()
|
||
.map_or("?".to_string(), |n| n.to_string()),
|
||
target["branch"].as_str().unwrap_or("unknown")
|
||
),
|
||
Some("branch") => format!(
|
||
"远端分支 {}(head SHA {head})",
|
||
target["branch"].as_str().unwrap_or("?")
|
||
),
|
||
_ => format!(
|
||
"主工作区当前分支 {}(本地 HEAD {head})",
|
||
target["branch"].as_str().unwrap_or("unknown")
|
||
),
|
||
}
|
||
}
|
||
|
||
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("?"),
|
||
delivery_target_summary(&value["target"]),
|
||
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_report(pid)
|
||
.and_then(|reports| render_inject_report(pid, &reports))
|
||
}
|
||
"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 query = parse_delivery_query(&args)?;
|
||
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, &query,
|
||
);
|
||
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 mut report =
|
||
crate::commands::onboarding::summarize_onboarding(pid, checks, runtime);
|
||
report.clients = crate::mcp_inject::client_static_status(pid);
|
||
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 })
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── 工具实现 ──────────────────────────────────────────────────────────────────
|
||
|
||
/// 诊断里的项目标签「名称 (`id`)」:只打 ID 人读不出是哪个项目(2026-10-03 把 enterprise-system 的
|
||
/// UUID 误读成炼境重复登记);日志里残留的已删除登记标注未登记,免得当成现存项目。
|
||
fn diagnostics_project_label(conn: &rusqlite::Connection, project_id: &str) -> String {
|
||
match conn.query_row(
|
||
"SELECT pp.name FROM project_workspaces pw
|
||
LEFT JOIN project_profiles pp ON pp.id = pw.profile_id
|
||
WHERE pw.id = ?1",
|
||
[project_id],
|
||
|r| r.get::<_, Option<String>>(0),
|
||
) {
|
||
Ok(Some(name)) => format!("{name} (`{project_id}`)"),
|
||
Err(rusqlite::Error::QueryReturnedNoRows) => format!("`{project_id}`(未登记)"),
|
||
_ => format!("`{project_id}`"),
|
||
}
|
||
}
|
||
|
||
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,
|
||
diagnostics_project_label(&conn, &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())?;
|
||
render_all_projects(&conn, &crate::credential_store::WindowsCredentialStore)
|
||
}
|
||
|
||
/// 「已注入」按 mcp_inject::is_project_injected(有项目 token)判定,与 DSH 白名单、根目录迁移、批量重注入同源;
|
||
/// 不再看项目里的 .claude/liangjing.json(兼容投影,可能残留或缺失)。
|
||
fn render_all_projects(
|
||
conn: &rusqlite::Connection,
|
||
store: &impl crate::credential_store::CredentialStore,
|
||
) -> Result<String, String> {
|
||
let wsl_distro = conn
|
||
.query_row(
|
||
"SELECT value FROM settings WHERE key = 'wsl_distro'",
|
||
[],
|
||
|r| r.get::<_, String>(0),
|
||
)
|
||
.unwrap_or_else(|_| "Ubuntu".to_string());
|
||
let mut stmt = conn
|
||
.prepare(
|
||
"SELECT pw.id, pp.name, pw.platform, pw.win_path, pw.wsl_path, pgm.group_id
|
||
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 inner = p.trim_start_matches('/').replace('/', "\\");
|
||
format!("\\\\wsl.localhost\\{}\\{}", wsl_distro, inner)
|
||
})
|
||
} else {
|
||
win_path.clone()
|
||
};
|
||
|
||
let inject_icon = if crate::mcp_inject::is_project_injected(store, id) {
|
||
"✅ 已注入"
|
||
} 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 ®istered_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 inject_report_lists_every_client_and_errors_only_on_codex_failure() {
|
||
use crate::onboard::clients::{ClientId, ClientOutcome, ClientReport};
|
||
let ok = [
|
||
ClientReport::new(ClientId::Claude, ClientOutcome::Converged),
|
||
ClientReport::new(ClientId::Codex, ClientOutcome::Applied),
|
||
ClientReport::new(ClientId::Dsh, ClientOutcome::NotInstalled),
|
||
];
|
||
let text = render_inject_report("p1", &ok).unwrap();
|
||
assert!(text.starts_with("MCP 注入完成"));
|
||
assert!(
|
||
text.contains("Claude Code:已一致")
|
||
&& text.contains("Codex:已写入")
|
||
&& text.contains("DSH:未安装")
|
||
);
|
||
// Claude 未就绪:如实列出、标部分完成,但不是工具错误(Claude 一直是 best-effort)
|
||
let claude_unavailable = [
|
||
ClientReport::new(
|
||
ClientId::Claude,
|
||
ClientOutcome::Unavailable {
|
||
reason: "claude CLI 未找到".into(),
|
||
},
|
||
),
|
||
ClientReport::new(ClientId::Codex, ClientOutcome::Converged),
|
||
];
|
||
let text = render_inject_report("p1", &claude_unavailable).unwrap();
|
||
assert!(
|
||
text.starts_with("MCP 注入部分完成") && text.contains("无法确认:claude CLI 未找到")
|
||
);
|
||
// Codex 托管块写入失败:工具报错(沿用旧语义)
|
||
let codex_failed = [ClientReport::new(
|
||
ClientId::Codex,
|
||
ClientOutcome::Failed {
|
||
reason: "块不完整".into(),
|
||
},
|
||
)];
|
||
assert!(render_inject_report("p1", &codex_failed)
|
||
.unwrap_err()
|
||
.contains("失败:块不完整"));
|
||
}
|
||
|
||
#[test]
|
||
fn list_all_projects_judges_injection_by_token_not_compat_file() {
|
||
let conn = crate::db::conn_with_schema();
|
||
let store = crate::credential_store::MemoryCredentialStore::new();
|
||
let root = std::env::temp_dir().join(format!("lj_list_inject_{}", std::process::id()));
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
std::fs::create_dir_all(root.join(".claude")).unwrap();
|
||
conn.execute(
|
||
"INSERT INTO project_profiles (id, name) VALUES ('p-profile', 'QA')",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
conn.execute(
|
||
"INSERT INTO project_workspaces (id, profile_id, win_path) VALUES ('p', 'p-profile', ?1)",
|
||
[root.display().to_string()],
|
||
)
|
||
.unwrap();
|
||
let injected = |out: &str| {
|
||
assert_ne!(
|
||
out.contains("✅ 已注入"),
|
||
out.contains("⚠️ 未注入"),
|
||
"单个项目只能有一种状态"
|
||
);
|
||
out.contains("✅ 已注入")
|
||
};
|
||
// 只有兼容投影 .claude/liangjing.json、没有 token:未注入
|
||
std::fs::write(root.join(".claude/liangjing.json"), "{}").unwrap();
|
||
assert!(!injected(&render_all_projects(&conn, &store).unwrap()));
|
||
// 有 token、删掉兼容文件:已注入(与 DSH 白名单、根目录迁移同一判定)
|
||
crate::credential_store::get_or_create_mcp_auth_token(&store, "p").unwrap();
|
||
std::fs::remove_file(root.join(".claude/liangjing.json")).unwrap();
|
||
assert!(injected(&render_all_projects(&conn, &store).unwrap()));
|
||
// 删除 token:未注入
|
||
crate::credential_store::delete_mcp_auth_token_for_project(&store, "p").unwrap();
|
||
assert!(!injected(&render_all_projects(&conn, &store).unwrap()));
|
||
let _ = std::fs::remove_dir_all(&root);
|
||
}
|
||
|
||
#[test]
|
||
fn tools_list_contains_expected_tools() {
|
||
let result = tools_list_result();
|
||
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("明确确认"));
|
||
}
|
||
|
||
#[test]
|
||
fn delivery_status_schema_offers_optional_pr_and_branch_targets() {
|
||
let result = tools_list_result();
|
||
let delivery = result["tools"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|tool| tool["name"] == "get_delivery_status")
|
||
.unwrap()
|
||
.clone();
|
||
let properties = &delivery["inputSchema"]["properties"];
|
||
assert_eq!(properties["pr_number"]["type"], "integer");
|
||
assert_eq!(properties["branch"]["type"], "string");
|
||
let required = delivery["inputSchema"]["required"].as_array().unwrap();
|
||
assert!(!required
|
||
.iter()
|
||
.any(|field| field == "pr_number" || field == "branch"));
|
||
assert!(delivery["description"]
|
||
.as_str()
|
||
.unwrap()
|
||
.contains("pr_number"));
|
||
|
||
let scoped = tools_list_result_for_project("p1");
|
||
let scoped_delivery = scoped["tools"]
|
||
.as_array()
|
||
.unwrap()
|
||
.iter()
|
||
.find(|tool| tool["name"] == "get_delivery_status")
|
||
.unwrap()
|
||
.clone();
|
||
assert_eq!(
|
||
scoped_delivery["inputSchema"]["properties"]["pr_number"]["type"],
|
||
"integer"
|
||
);
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn delivery_status_rejects_invalid_target_before_io() {
|
||
for (arguments, expected) in [
|
||
(
|
||
json!({"project_id": "p1", "pr_number": 0}),
|
||
"pr_number 必须为正整数",
|
||
),
|
||
(
|
||
json!({"project_id": "p1", "pr_number": "31"}),
|
||
"pr_number 必须为正整数",
|
||
),
|
||
(
|
||
json!({"project_id": "p1", "branch": 5}),
|
||
"branch 必须为字符串",
|
||
),
|
||
(
|
||
json!({"project_id": "p1", "branch": "../main"}),
|
||
"branch 不是有效的 Git 分支名",
|
||
),
|
||
] {
|
||
let result = tools_call(Some(&json!({
|
||
"name": "get_delivery_status",
|
||
"arguments": arguments
|
||
})))
|
||
.await;
|
||
assert_eq!(result["isError"], true, "{arguments}");
|
||
let text = result["content"][0]["text"].as_str().unwrap_or_default();
|
||
assert!(text.contains(expected), "{arguments} → {text}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn delivery_summary_names_the_queried_target_and_full_head_sha() {
|
||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||
let response = tool_success_response(
|
||
"get_delivery_status",
|
||
json!({
|
||
"project_id": "p1",
|
||
"target": {
|
||
"mode": "pull_request",
|
||
"pr_number": 31,
|
||
"branch": "feat/delivery-status-by-pr",
|
||
"head_sha": sha,
|
||
"source": "gitea"
|
||
},
|
||
"git": { "state": "dirty" },
|
||
"pull_request": { "state": "open" },
|
||
"ci": { "state": "passed" },
|
||
"review": { "state": "pending" },
|
||
"deployment": { "state": "not_configured" }
|
||
})
|
||
.to_string(),
|
||
);
|
||
let text = response["content"][0]["text"].as_str().unwrap();
|
||
assert!(text.contains("PR #31"), "{text}");
|
||
assert!(text.contains(sha), "{text}");
|
||
assert!(text.contains("feat/delivery-status-by-pr"), "{text}");
|
||
}
|
||
|
||
/// 使者回传 #203 的症状正是「传 pr_number 也不起作用」:经 scoped dispatch 断言
|
||
/// pr_number/branch 真正到达能力本体(未绑定 Gitea,不发网络请求)。
|
||
#[tokio::test]
|
||
async fn delivery_status_dispatch_forwards_pr_and_branch_targets() {
|
||
db::init_test_pool();
|
||
let base = temp_base("delivery_target_dispatch");
|
||
{
|
||
let conn = db::pool().get().unwrap();
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO project_profiles (id, name) VALUES ('delivery-target-profile', 'Delivery target fixture')",
|
||
[],
|
||
)
|
||
.unwrap();
|
||
conn.execute(
|
||
"INSERT OR IGNORE INTO project_workspaces (id, profile_id, win_path) VALUES ('delivery-target', 'delivery-target-profile', ?1)",
|
||
[base.to_str().unwrap()],
|
||
)
|
||
.unwrap();
|
||
}
|
||
let target_of = |result: &Value| {
|
||
assert_ne!(result["isError"], true, "{result}");
|
||
result["structuredContent"]["target"].clone()
|
||
};
|
||
|
||
let by_pr = tools_call_for_project(
|
||
Some(&json!({"name": "get_delivery_status", "arguments": {
|
||
"pr_number": 31, "branch": "feat/worktree"
|
||
}})),
|
||
"delivery-target",
|
||
)
|
||
.await;
|
||
let target = target_of(&by_pr);
|
||
assert_eq!(target["mode"], "pull_request", "{by_pr}");
|
||
assert_eq!(target["pr_number"], 31, "{by_pr}");
|
||
assert_eq!(
|
||
by_pr["structuredContent"]["pull_request"]["state"],
|
||
"not_configured"
|
||
);
|
||
|
||
let by_branch = tools_call_for_project(
|
||
Some(&json!({"name": "get_delivery_status", "arguments": {
|
||
"branch": "feat/worktree"
|
||
}})),
|
||
"delivery-target",
|
||
)
|
||
.await;
|
||
let target = target_of(&by_branch);
|
||
assert_eq!(target["mode"], "branch", "{by_branch}");
|
||
assert_eq!(target["branch"], "feat/worktree", "{by_branch}");
|
||
|
||
let default = tools_call_for_project(
|
||
Some(&json!({"name": "get_delivery_status", "arguments": {
|
||
"pr_number": null, "branch": null
|
||
}})),
|
||
"delivery-target",
|
||
)
|
||
.await;
|
||
assert_eq!(target_of(&default)["mode"], "local_branch", "{default}");
|
||
let _ = std::fs::remove_dir_all(&base);
|
||
}
|
||
|
||
#[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 批量模式)"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn diagnostics_inject_label_shows_name_and_flags_unregistered() {
|
||
let conn = crate::db::conn_with_schema();
|
||
conn.execute_batch(
|
||
"INSERT INTO project_profiles (id, name) VALUES ('prof-e','enterprise-system');
|
||
INSERT INTO project_workspaces (id, profile_id, win_path) VALUES ('uuid-e','prof-e','E:/e');",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
diagnostics_project_label(&conn, "uuid-e"),
|
||
"enterprise-system (`uuid-e`)"
|
||
);
|
||
assert_eq!(diagnostics_project_label(&conn, "gone"), "`gone`(未登记)");
|
||
}
|
||
}
|