diff --git a/.gitignore b/.gitignore index d16e448..21c1456 100644 --- a/.gitignore +++ b/.gitignore @@ -162,3 +162,9 @@ logs/ # Cache directories cache/ models/ + +# Heicode service-account credentials — never commit +Docs/heicode-svc-token.txt +Docs/heicode-internal-token.txt +Docs/*.token +Docs/*-secret.* diff --git a/Docs/Agent-Manager-Heicode对接需求文档.md b/Docs/Agent-Manager-Heicode对接需求文档.md new file mode 100644 index 0000000..e3e16d8 --- /dev/null +++ b/Docs/Agent-Manager-Heicode对接需求文档.md @@ -0,0 +1,660 @@ +# Agent-Manager (= Heicode Agnet 平台) 对接需求文档 + +**版本**: v1.1 +**生效日期**: 2026-05-07 +**目标读者**: agent-manager 服务的开发团队 +**对接方**: mcp-server(Heicode Manager) +**依据**: +- Heicode 主线:`heicode.md` / `plan.md` +- 接口契约:`integration/agnet-platform-request-contract.md` +- 运行时设计:`heicode-runtime-auth-newapi-secret-design.md` + +**v1.1 修订**(2026-05-07,按 Heicode 团队 4 路径架构修订): +- §1.1 架构图:反映双模型网关(NewAPI + LiteLLM)并存 +- §3.1 payload 校验:新增 `billing_context.provider` enum 约束(`newapi` | `litellm`) +- §4.1 Pod 启动:按 provider 注入不同 token(`HEICODE_NEWAPI_USER_TOKEN` 或 `LITELLM_USER_KEY`) +- §3a(**新增**):子 Agent 模型网关路由说明 + +**配套文档**: +- 调用关系全景:[`Heicode-完整调用流程图.md`](./Heicode-完整调用流程图.md) +- mcp-server 已上线接口:[`Heicode-接口契约文档.md`](./Heicode-接口契约文档.md) +- 整体进度与待办:[`Heicode-对接进度与待办.md`](./Heicode-对接进度与待办.md) + +--- + +## 0. TL;DR + +agent-manager 在 Heicode 架构里担任 **Agnet 平台**角色——**执行层**,运行子 Agent、回传日志/事件/审计。 + +需要做三件事: + +1. **新增 12 个 HTTP 接口**(`/api/agnet/*`),接收 mcp-server 的部署请求并回传状态 +2. **改 Pod 启动方式**:子 Agent Pod 启动时只接收 `AGENT.md` + `resource_context` + `permission_manifest`,**不再接收长期密钥** +3. **接入 AKS Workload Identity**:子 Agent Pod 通过 ServiceAccount 拿身份,按需从 Vault 拉短期凭据 + +⚠️ **现有 agent-manager 接口不动**(taiji 业务还在用),**全部增量**。 + +--- + +## 1. 背景与边界 + +### 1.1 Heicode 全栈架构(v1.1 修订:4 路径模型调用) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 入口层 │ +│ cc-haha 桌面客户端 heicode web 前端 │ +│ (Tauri + Bun) (React + Rsbuild) │ +└─────────────────────────────────────────────────────────────────┘ + │ │ + │ 登录 4 接口 │ + └───────────┬───────────────────────┘ + ▼ + ┌──────────────────────────────────┐ + │ Heicode Manager (mcp-server) │ + │ ✅ 登录 IdP │ + │ ✅ ResourceBinding/Grant │ + │ ❌ /api/agnet/* (12 接口) │ + │ ❌ /api/user/heicode/* (4 透传) │ + └──────┬───────────┬───────────┬───┘ + │ │ │ + 部署请求 │ │ NewAPI 元数据查询 + │ │ (service token) + ▼ ▼ + ┌────────────────────────────────────────┐ + │ ★ 你要做的:agent-manager (Agnet 平台)│ + │ - 12 个新接口 │ + │ - 创建 K8s Deployment │ + │ - 按 billing_context.provider 路由 │ + └──────────────┬───────────────────────┬──┘ + │ │ + provider=newapi│ provider=litellm │ + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────┐ + │ 子 Agent Pod │ │ 子 Agent Pod │ + │ (Heicode 用户的) │ │ (taijiagent 用户的) │ + │ ENV: │ │ ENV: │ + │ HEICODE_NEWAPI_ │ │ LITELLM_USER_KEY │ + │ USER_TOKEN │ │ LITELLM_BASE_URL │ + └──────────┬───────────┘ └──────────┬───────────┘ + │ /v1/chat/completions │ /v1/chat/completions + ▼ ▼ + ┌──────────────────────┐ ┌──────────────────────┐ + │ Heicode NewAPI │ │ taijiagent LiteLLM │ + │ code.xinghanlab.com │ │ (mcp-server 内置) │ + └──────────────────────┘ └──────────────────────┘ + │ │ + └────────────┬────────────┘ + ▼ + 40+ AI 提供商(OpenAI、Claude、Gemini...) +``` + +**boundary**: +- Manager (mcp-server) = **用户控制台 + 编排中枢**,不直接动 K8s +- Agnet 平台 (agent-manager) = **执行层**,唯一接触 K8s deployment 的服务 +- Manager 通过 HTTP 调 Agnet 平台,**不**直接调 K8s API +- **★ 重要**:模型调用是 4 路径(cc-haha + heicode 前端 → NewAPI;子 Agent → NewAPI 或 LiteLLM 看 provider;mcp-server 内部 → LiteLLM),详见 [`Heicode-完整调用流程图.md §2.5`](./Heicode-完整调用流程图.md) + +### 1.2 不要做什么 + +| 不要做 | 为什么 | +|---|---| +| ❌ 在 agent-manager 里再发起高危操作审批 | 审批只在客户端做,agent-manager 只**校验** approval_id 是否有效 | +| ❌ 直接信任 mcp-server 传来的 role 提升 | 高权限角色由 Vault policy / K8s RBAC 强制,不靠应用层声明 | +| ❌ 把长期密钥(Git PAT、云 access key)注入 Pod env | Pod 只能拿短期、最小权限凭证;长期密钥放 Vault | +| ❌ 让 Pod 直连 mcp-server 拿用户上下文 | 上下文应在创建 Deployment 时一次性写入 K8s Secret/ConfigMap | +| ❌ 替换或破坏现有 agent-manager 老接口 | taiji 业务(channel admin → 创建 Agent → 部署)正在用,必须向后兼容 | + +--- + +## 2. 12 个新接口(必须实现) + +完整字段定义见 [`integration/agnet-platform-request-contract.md`](http://gitee.ath.cx:3000/xiaohei/heicode/src/branch/main/docs/integration/agnet-platform-request-contract.md)。 + +下表是必须实现的 11 个接口 + 1 个可选 SSE: + +| 序号 | 接口 | 用途 | mcp-server 何时调 | +|---|---|---|---| +| 1 | `POST /api/agnet/deployments` | 创建子 Agent 部署 | 用户在 Manager 点"部署" | +| 2 | `GET /api/agnet/deployments` | 列表 | Manager 显示"我的部署"页 | +| 3 | `GET /api/agnet/deployments/{id}` | 详情 | Manager 显示部署详情页 | +| 4 | `POST /api/agnet/deployments/{id}/stop` | 停止 | 用户点"停止"或预算超 | +| 5 | `GET /api/agnet/deployments/{id}/logs` | 日志(脱敏) | 用户看子 Agent 输出 | +| 6 | `GET /api/agnet/deployments/{id}/logs/stream` | SSE 实时日志 | (可选)实时控制台 | +| 7 | `GET /api/agnet/projects/{binding_scope}/dashboard-snapshot` | 资源作用域监控快照 | Manager 总览页 | +| 8 | `GET /api/agnet/deployments/{id}/metrics` | 单部署指标序列 | Manager 详情页"性能"tab | +| 9 | `GET /api/agnet/deployments/{id}/events` | 事件流 | Manager 详情页"事件"tab | +| 10 | `GET /api/agnet/audit-logs` | 审计日志 | Manager 审计页 | +| 11 | `POST /api/agnet/sk-snapshots/resolve` | 触发 SK 快照解析 | Manager 拉取/刷新 SK | +| 12 | `GET /api/agnet/deployments/{id}/sk-snapshots` | 查询 SK 快照 | Manager 部署详情 | + +### 2.1 mcp-server 调用 agent-manager 的认证模型 + +mcp-server 用**服务身份令牌**(service token)调 agent-manager,**不传**用户凭据: + +```http +POST /api/agnet/deployments +Authorization: Bearer +Content-Type: application/json +X-Correlation-Id: +X-User-Id: +X-Binding-Scope: +Idempotency-Key: # 创建类接口建议 +``` + +| Header | 必填 | 说明 | +|---|---|---| +| `Authorization: Bearer ` | 是 | manager 的服务令牌;agent-manager 校验签名/有效期 | +| `X-Correlation-Id` | 是 | mcp-server 生成;全链路追踪 ID | +| `X-User-Id` | 建议 | 实际终端用户 ID;冗余于 body `user_context.user_id` | +| `X-Binding-Scope` | 建议 | 当前操作的资源作用域;冗余于 body `resource_grants[].binding_scope` | +| `Idempotency-Key` | 创建类建议 | mcp-server 生成;agent-manager 缓存幂等结果 | + +**待决策**:服务令牌怎么发?三种方案: + +| 方案 | 说明 | +|---|---| +| (A) Pre-shared bearer | mcp-server 配 env `AGENT_MANAGER_SERVICE_TOKEN`;agent-manager 配等值校验。最简单 | +| (B) JWT 签发 | 共享 secret 签发短期 JWT;agent-manager 校验签名 | +| (C) AKS Workload Identity | mcp-server pod 用 SA 拿 token;agent-manager 校验 OIDC issuer。最规范 | + +mcp-server 团队建议: **(A) 先做,后期升 (C)**。请告知你们偏好。 + +### 2.2 通用响应包裹 + +成功: +```json +{ "success": true, "data": { ... } } +``` + +失败(**结构化必填**): +```json +{ + "success": false, + "error": { + "code": "POLICY_REJECTED", + "message": "human readable", + "request_id": "req_xxx" + } +} +``` + +mcp-server 会按 business `code` 路由处理。建议 code 集合: + +| code | 场景 | mcp-server 行为 | +|---|---|---| +| `POLICY_REJECTED` | 缺必填、风险等级非法 | 显示校验错误,不重试 | +| `BUDGET_EXCEEDED` | 超 token/金额/时长预算 | 显示预算告警 | +| `MODEL_NOT_ALLOWED` | 模型不在 allowed_model_ids 内 | 显示模型未授权 | +| `FORBIDDEN_SCOPE` | header 与 body 用户/资源作用域不一致 | 阻断 + 写审计 | +| `RESOURCE_GRANT_INVALID` | resource_grants 字段缺失 / 跨用户 / 角色不匹配 | 拒绝部署 | +| `RESOURCE_GRANT_SECRET_REJECTED` | 请求中出现明文密钥字段 | 让 mcp-server 重新生成 payload | +| `SK_SOURCE_UNRESOLVABLE` | SK 来源不可解析 | 重试或提示 | +| `DEPLOYMENT_CONFLICT` | 部署不存在 / 状态冲突 / 重复提交 | 用 Idempotency-Key 查既有结果 | +| `NOT_FOUND` | 资源不存在 | 返回空态 | +| `CURSOR_EXPIRED` | 分页游标过期 | 弃 cursor,重新拉 | +| `RATE_LIMITED` | 限流 | 按 `Retry-After` 退避 | +| `INTERNAL_ERROR` | 内部错误 | 退避重试 + 人工排查 | + +--- + +## 3. 接口详情速览(agent-manager 视角) + +> 完整 payload 字段见 heicode 仓库的 `agnet-platform-request-contract.md`,本节只给你们 server 端实现要点。 + +### 3.1 POST /api/agnet/deployments — 创建部署 + +**收到 payload 后必须做的事**: + +1. **服务令牌校验** —— 401 否则 +2. **Idempotency-Key 查重** —— 若同 key 已处理,返回原结果(不重复创建 K8s deployment) +3. **payload 字段校验**: + - `orchestration_plan.intent_id` / `template_hint` / `objective` / `risk_level` / `budget` / `metadata.correlation_id` / `agents[]` 必填 + - `risk_level=high` 时 `agents[].resource_grants[].constraints.approval_id` 必须存在 + - `agents[].default_model_id` 若设置,必须 ∈ `constraints.allowed_model_ids` + - `resource_grants[]`:`grant_id` / `resource_id` / `resource_type` / `user_id` / `binding_scope` / `target_role` / `target_agent_ref` / `permission_scope` / `status` 必填 + - 凭据型资源(git/sk/cloud_account/cloud_resource):`secret_ref` 必填;`project_doc` 可空 + - **`billing_context.provider`**(Heicode 2026-05-07 修订):枚举 = `newapi` | `litellm` + - `newapi` → 子 Agent Pod 调模型走 Heicode NewAPI(`code.xinghanlab.com`) + - `litellm` → 子 Agent Pod 调模型走 taijiagent LiteLLM + - agent-manager 据此决定 Pod env 注入哪个 token:`HEICODE_NEWAPI_USER_TOKEN` 或 `LITELLM_USER_KEY` + - **agent-manager 不需要做产品决策,仅按 mcp-server 传来的值路由** +4. **敏感字段拒绝**:递归扫 `metadata` / `constraints` / `audit`,key 含 `password|token|secret|private_key|access_key|credential` → `RESOURCE_GRANT_SECRET_REJECTED` +5. **审批校验**(仅 risk_level=high): + - `approval_id` 在 `constraints` 或 `audit` 中 + - 审批主体 ∈ `user_context.user_id` / `resource_grants[].user_id` + - 审批未过期(含 TTL / window) + - 范围覆盖 `binding_scope` + `permission_scope` + 目标环境 + 资源 ID +6. **创建 K8s Deployment**: + - 命名空间:建议 `agnet-{user_id 短哈希}` 或现有规则 + - ServiceAccount:按 `agents[].role_template` + user_id 派生(见 §4) + - Pod 启动配置:把 AGENT.md + resource_context + permission_manifest 写入 ConfigMap,挂到 Pod + - **不写明文密钥**到 env / configmap +7. **返回**: + ```json + { + "success": true, + "data": { + "deployment_id": "dep_xxx", + "status": "accepted", + "agent_instances": [ + { "instance_id": "agi_xxx", "role": "builder", "phase": "pending" } + ] + } + } + ``` + +### 3.2 POST /api/agnet/deployments/{id}/stop — 停止 + +- 已停止 → 200 + `status=stopped`(幂等) +- 进入终态(如 `completed`)且无运行实例 → 409 `DEPLOYMENT_CONFLICT` +- 高风险停止缺审批 → 422 `POLICY_REJECTED` + +### 3.3 GET /api/agnet/deployments/{id}/logs — 日志(**强制脱敏**) + +**返回前必须做**:扫描 `message` 字段,删/掩盖任何疑似密码、token、私钥、连接串、access key 的字符串。 + +字段: +```json +{ + "log_id": "log_xxx", + "deployment_id": "dep_xxx", + "agent_instance_id": "agi_xxx", + "stream": "stdout|stderr|system|audit", + "level": "info|warn|error", + "message": "task started", + "redacted": true, + "occurred_at": "ISO 8601" +} +``` + +支持 query:`agent_instance_id`、`stream`、`since`、`limit`(默认 200,建议 max 1000)、`cursor`。 + +### 3.4 GET /api/agnet/deployments/{id}/events — 事件 + +至少实现这些事件名: +- `deployment.accepted` — 平台接受请求 +- `instance.phase_changed` — 子 Agent phase 变化 +- `sk_snapshot_refreshed` — SK 快照刷新 +- `resource_grant.attached` / `resource_grant.revoked` — 授权绑定/撤销 +- `budget.threshold_reached` — 预算触发 +- `deployment.failed` — 部署失败 + +字段:`event_id` / `event` / `schema_version` / `user_id` / `channel_id` / `binding_scope` / `deployment_id` / `correlation_id` / `occurred_at`。 + +### 3.5 GET /api/agnet/projects/{binding_scope}/dashboard-snapshot — 监控快照 + +> 注意路径里写 `projects/{binding_scope}` 是契约保留旧名;参数值是 `binding_scope` 不是 project_id。 + +返回:active_instances、phase_distribution、failure_rate_1h、avg_task_duration、budget(tokens/cost/duration)、resource_usage(cpu/mem/network)、updated_at。 + +### 3.6 GET /api/agnet/deployments/{id}/metrics — 单部署指标(建议) + +返回时间序列: +- `tokens_used` (count) +- `cost_usd` (number) +- `duration_sec` (count) +- `cpu_millicores` (millicore) +- `memory_mb` (mb) +- `restart_count` / `tool_call_count` / `error_count` / `queue_latency_ms` + +支持 `window=15m&step=60s` 等参数。 + +### 3.7 GET /api/agnet/audit-logs — 审计日志 + +字段:`audit_id` / `actor` / `action` / `resource` / `user_id` / `channel_id` / `binding_scope` / `request_id` / `correlation_id` / `result` / `occurred_at`。 + +支持 query:`user_id`、`binding_scope`、`actor`、`action`、`since`、`limit`、`cursor`。 + +### 3.8 POST /api/agnet/sk-snapshots/resolve — SK 快照解析 + +请求:`{"deployment_id": "dep_xxx"}` + +服务端动作:把 deployment 的 `agents[].sk_sources[]` 里的 git/upload 资源拉取下来,生成只读快照(**不带凭据**),生成 `snapshot_id` + `artifact_ref` + `checksum`。 + +### 3.9 GET /api/agnet/deployments/{id}/sk-snapshots — SK 快照查询 + +返回 snapshots 列表,含 `source_ref`(如 `main:skills/heicode/**@sha_xxx`)、`resolved_at`、`status: ready/resolving/failed`。 + +--- + +## 3a. 子 Agent 模型网关路由(v1.1 新增 — 必须实现) + +### 3a.1 背景:为什么有这个章节 + +按 Heicode 团队 2026-05-07 的修订(详见 [`Heicode-完整调用流程图.md §2.5`](./Heicode-完整调用流程图.md)),整个生态有**两套并存的产品级模型网关**: + +| 网关 | 服务对象 | provider 字段值 | +|---|---|---| +| **Heicode NewAPI** (`code.xinghanlab.com`) | cc-haha 桌面端用户、Heicode 用户部署的子 Agent | `"newapi"` | +| **taijiagent LiteLLM** | 原生 taijiagent 用户、taijiagent 用户部署的子 Agent | `"litellm"` | + +mcp-server 创建 deployment 时会在 `billing_context.provider` 字段告诉 agent-manager:"这个子 Agent 调模型走哪条网关"。 + +**agent-manager 不需要做产品决策**,只按 provider 字段路由。 + +### 3a.2 校验规则(agent-manager 在 §3.1 step 3 校验) + +| 字段 | 取值 | 行为 | +|---|---|---| +| `billing_context.provider` | `"newapi"` | 走 Heicode NewAPI | +| `billing_context.provider` | `"litellm"` | 走 taijiagent LiteLLM | +| 缺失 / 其他值 | — | 返回 422 `POLICY_REJECTED`,message 提示有效取值 | + +### 3a.3 token 来源约定 + +mcp-server 在 `resource_grants[]` 里会传 `secret_ref` 指向用户的模型调用 token: + +```json +{ + "billing_context": { + "provider": "newapi", + "newapi_user_ref": "newapi_user_123", + "newapi_group": "development", + "quota_ref": "newapi_token_or_group_quota_ref" + }, + "agents": [{ + "resource_grants": [ + { + "resource_type": "model_gateway_token", + "secret_ref": "vault://secret/users/{user_id}/heicode/newapi_user_token", + ... + } + ] + }] +} +``` + +agent-manager 实现时: +- 拿到 deployment 后,按 provider 找出对应的 `secret_ref` +- 通过 Vault Kubernetes Auth 拿真实 token +- 注入 Pod env(详见 §4.1 步骤 4) + +### 3a.4 联调阶段简化(Phase 2-3 可接受) + +Phase 2-3 联调时如果 Vault 还没就位,**允许临时用预共享 token**(agent-manager pod env 配一个测试用 token)作为 fallback,但必须: +- 标注 `Deployment.metadata.annotations["heicode.io/token-source"] = "fallback-shared"` +- Phase 5 (Vault 接入) 完成后立即删除 fallback 路径 +- 测试用 token 限额低(例如 $1/day) + +### 3a.5 模型调用路径汇总 + +``` +子 Agent Pod (provider=newapi): + POST /v1/chat/completions + Authorization: Bearer ${HEICODE_NEWAPI_USER_TOKEN} + ↓ + https://code.xinghanlab.com (Heicode NewAPI) + ↓ + 转发到 OpenAI / Claude / Gemini / ... + +子 Agent Pod (provider=litellm): + POST /v1/chat/completions + Authorization: Bearer ${LITELLM_USER_KEY} + ↓ + ${LITELLM_BASE_URL} (taijiagent LiteLLM) + ↓ + 转发到 OpenAI / Claude / Gemini / ... +``` + +两条路径**互不替代**,由 provider 字段一次性决定。 + +--- + +## 4. Pod 启动行为改造(必须) + +依据 `heicode.md §七 AKS 上的 Agnet 凭证访问`。 + +### 4.1 推荐流程 + +``` +mcp-server POST /api/agnet/deployments (含 user_id, role, resource_grants, secret_refs, + billing_context.provider) + ↓ +agent-manager: + 1. 在 AKS 创建 ServiceAccount(命名规则:sa-{role}-{user_id 短哈希}) + 2. 给 SA 绑定 Vault Kubernetes Auth role(pol 路径包含 user_id + binding_scope) + 3. 创建 ConfigMap:AGENT.md + resource_context.json + permission_manifest.json + 4. ★ 按 billing_context.provider 路由模型网关 token: + - provider=newapi → 从 secret_ref 拿 Heicode NewAPI user token + 注入 Pod env: + HEICODE_NEWAPI_BASE_URL=https://code.xinghanlab.com + HEICODE_NEWAPI_USER_TOKEN=<从 Vault/secret_ref 取> + - provider=litellm → 从 secret_ref 拿 LiteLLM user key + 注入 Pod env: + LITELLM_BASE_URL=<内网 LiteLLM 地址> + LITELLM_USER_KEY=<从 Vault/secret_ref 取> + 5. 创建 Deployment,spec: + - serviceAccountName: <上面那个 SA> + - volumeMounts: ConfigMap 挂到 /etc/agent/ + - env (Vault 部分): + VAULT_ADDR: 内网 Vault 地址 + VAULT_AUTH_PATH: /auth/kubernetes/login + VAULT_ROLE: <上面 SA 绑定的 role> + - env (模型网关部分): 见步骤 4 按 provider 决定 + - **不**写任何 GIT_TOKEN、AZURE_KEY 等业务凭据明文 env + ↓ +Pod 启动: + - 读 ConfigMap 里的 AGENT.md / resource_context / permission_manifest + - 调模型时用 HEICODE_NEWAPI_USER_TOKEN 或 LITELLM_USER_KEY + - 调外部业务凭据(如 git clone)时,用 SA token 调 Vault 拿短期凭证,用完即弃 +``` + +> **关于模型 token 注入的安全权衡**(v1.1 补充): +> NewAPI/LiteLLM user token 是"模型调用费用归属凭据",不是"业务最高权限凭据"。把它作为 env 一次性注入是 Heicode 团队认可的妥协方案(避免每次调模型都过 Vault)。Token 必须满足: +> - 由 Heicode/taijiagent 平台**按 user 分发**(不是 admin token) +> - **TTL 短**(建议 24h)或可被快速撤销 +> - **额度受限**(不超过用户 budget) +> - agent-manager 在 Deployment annotation 里记 `secret_ref` 引用,便于审计/吊销追溯 +> - Pod 销毁时 token 也跟 Pod env 一起消失 + +### 4.2 ConfigMap 三个文件的格式建议 + +**AGENT.md**(自然语言上下文): +```markdown +# Role: backend builder +# Goal: 在 services/api/** 路径下完成实现并提交代码 +# Resources you can use: +- Git: (ref: main, paths: services/api/**, actions: read/write) +- Models: gpt-5.4-mini (max_tokens: 100000) +# Forbidden: +- 修改 services/api/** 之外的文件 +- 创建新分支 +``` + +**resource_context.json**(结构化资源元数据,**无密钥**): +```json +{ + "agent_role": "backend", + "deployment_id": "dep_xxx", + "resources": [ + { + "resource_id": "res_git_001", + "type": "git", + "external_ref": "https://example.com/org/repo.git", + "constraints": { "ref": "main", "allowed_paths": "services/api/**" }, + "secret_ref": "vault://secret/users/{user_id}/bindings/repo_default/resources/res_git_001" + } + ] +} +``` + +**permission_manifest.json**(结构化权限清单,**给系统强制执行用**): +```json +{ + "user_id": "user_123", + "binding_scope": "repo_default", + "agent_role": "backend", + "resource_grants": [ + { + "grant_id": "grant_xxx", + "resource_type": "git", + "allowed_actions": ["repo:read"], + "constraints": { "ref": "main", "allowed_paths": "services/api/**" }, + "secret_ref": "vault://..." + } + ] +} +``` + +### 4.3 强制规定 + +| 项 | 必须 | 不得 | +|---|---|---| +| Pod env | 仅 VAULT_ADDR / VAULT_ROLE / 公开配置 | 任何长期凭据、连接串、token、密码 | +| ConfigMap 内容 | 元数据 + secret_ref 引用 | 凭据原文 | +| Pod 日志 | 脱敏后输出 | 凭据片段、env dump | +| Pod 镜像 | 公共 base + 启动 script | 凭据嵌入到镜像 | +| Vault 访问 | 通过 SA + Kubernetes Auth | Pod 直接拿 root token | + +--- + +## 5. AKS 基础设施对齐(与基础设施团队协作) + +### 5.1 Workload Identity 启用 + +- AKS 集群启用 OIDC issuer + Workload Identity addon +- 命名空间级 ServiceAccount 标注: + ```yaml + metadata: + annotations: + azure.workload.identity/client-id: + ``` +- 给 SA 配 Federated Identity Credential 关联到 Azure AD + +### 5.2 Vault Kubernetes Auth 配置 + +```hcl +# Vault policy: per (user_id, binding_scope) 派生 +path "secret/users/${user_id}/bindings/${binding_scope}/resources/*" { + capabilities = ["read"] +} + +# Kubernetes Auth role: 绑定 SA → policy +{ + "bound_service_account_names": ["sa-backend-${user_id_hash}"], + "bound_service_account_namespaces": ["agnet-${user_id_hash}"], + "policies": ["heicode-${user_id}-${binding_scope}"], + "ttl": "1h" +} +``` + +### 5.3 网络策略 + +- agent-manager → Vault:内网;Vault 不暴露公网 +- Pod → Vault:通过 K8s service 或 private endpoint +- Pod → Git/Cloud:按 `network_policy_ref` 限制出站 + +--- + +## 6. 当前业务影响(保证现有 taiji 业务不挂) + +agent-manager 当前接口(核实自 mcp-server 老代码 `app/agent_manager_client.py`,2026-05-05): + +| 方法 | 路径 | mcp-server 调用方 | +|---|---|---| +| GET | `/templates` | 列模板 | +| GET | `/templates/platform` | 平台模板 | +| GET | `/templates/custom` | 自定义模板 | +| GET | `/templates/{template_name}` | 单模板详情 | +| POST | `/agents` | 创建 Agent(payload: AgentConfig) | +| GET | `/agents` | 列 Agent | +| GET | `/agents/{name}/status` | Agent 状态 | +| GET | `/agents/{name}/metrics` | Agent 指标 | +| GET | `/agents/{name}/logs` | Agent 日志 | +| DELETE | `/agents/{name}` | 删除 Agent | +| POST | `/agents/{name}/restart` | 重启 | +| PATCH | `/agents/{name}` (scale) | 扩缩容 | +| POST | `/external-tools/{tool_id}` 等 | 外部工具生成/更新/删除 | +| POST | `/external-tools/agents/create-with-tools` | 用工具集创建 Agent | +| GET | `/resources/stats` | 资源统计 | +| GET | `/resources/user/{id}` | 用户资源 | +| GET | `/resources/channel/{id}` | 渠道资源 | +| GET | `/health` | 健康检查 | + +**前缀对比**: +- 老 API:根路径下 `/templates/*`、`/agents/*`、`/external-tools/*`、`/resources/*`、`/health` +- 新 Heicode 契约:`/api/agnet/*` + +**纪律(已经核实无冲突)**: +- ✅ 前缀完全不重叠 → 老接口和新接口可以**并存** +- ✅ 路径冲突 = 0 +- ❌ 不改老接口路径、字段、响应形态 +- ❌ 不改老的 K8s namespace 命名规则(taiji 老 Agent 还在跑) + +**纪律**: +- ✅ 全部新增 12 个接口在 `/api/agnet/*` 前缀下 +- ❌ 不改老接口路径、字段、响应形态 +- ❌ 不改老的 K8s namespace 命名规则(taiji 老 Agent 还在跑) +- ✅ 新建用 `agnet-*` namespace,与老 namespace 隔离 + +--- + +## 7. 联调计划 + +### Phase 1: 服务令牌打通(半天) +1. mcp-server 配置环境变量 `AGENT_MANAGER_SERVICE_TOKEN` +2. agent-manager 实现 token 校验中间件 +3. mcp-server 写一个 dummy 调用,确认 401/200 通畅 + +### Phase 2: POST /api/agnet/deployments 通跑(2-3 天) +1. agent-manager 实现接口(不要求真起 Pod,先打日志返回 mock deployment_id) +2. mcp-server 写出站客户端 +3. 联调 payload 校验、错误码、Idempotency-Key + +### Phase 3: 状态/日志/事件/审计(3-5 天) +- agent-manager 实现 GET 类接口 +- 至少能返回 mock 数据或真实 K8s 数据 + +### Phase 4: 真实 Pod 部署(5-7 天) +- 接入 K8s API 真起 Deployment +- ConfigMap 写 AGENT.md / resource_context / permission_manifest +- Pod 启动后能读到这些文件 + +### Phase 5: AKS Workload Identity + Vault(1-2 周) +- 基础设施部署 Vault +- ServiceAccount + Workload Identity 联通 +- Pod 通过 SA 调 Vault 拿短期凭据 + +--- + +## 8. mcp-server 这边能给的支持 + +mcp-server(Heicode Manager)已经准备好的: + +| 项 | 状态 | +|---|---| +| ResourceBinding/Grant 数据模型 + 9 个 CRUD 接口 | ✅ 已上线 | +| 登录联邦(heicode 调 mcp-server `/me` `/refresh`)| ✅ 已上线 | +| 从 mcp-server 出站调 agent-manager 的客户端代码 | ⏳ 等 agent-manager 接口 ready 后做(~3-5 天) | +| 本地 stub `/api/agnet/*` 给前端联调用 | ⏳ 1-2 天可交付 | + +**请 agent-manager 团队尽快确认**: +- ❓ 你们偏好哪种服务令牌方案(A pre-shared / B JWT / C Workload Identity)? +- ❓ 你们的开发节奏?(按 §7 phase 排,预计 3-4 周完整闭环) +- ❓ 联调环境地址:staging 用什么 base URL?mcp-server 这边怎么配? +- ❓ 现有 agent-manager 老接口的契约文档在哪?mcp-server 老代码还在调,避免迁移时踩坑 + +--- + +## 9. 快速导航 + +| 我想了解… | 看哪 | +|---|---| +| Heicode 整体边界 | `heicode.md`(heicode 仓库 docs/) | +| 12 接口完整 payload | `integration/agnet-platform-request-contract.md` | +| Pod 启动安全约束 | `heicode.md §七` + `heicode-runtime-auth-newapi-secret-design.md §三` | +| mcp-server 已上线接口 | `Docs/Heicode-接口契约文档.md`(mcp-server 仓库) | +| 整体进度与待办 | `Docs/Heicode-对接进度与待办.md`(mcp-server 仓库) | +| 部署安全清单 | `deployment/azure-production-deploy-guardrails.md`(heicode 仓库) | + +--- + +## 10. 联系 + +mcp-server 这边联系点: +- 出站客户端代码改动:mcp-server 后端 +- 接口契约对齐:见 §2.2 错误码表与 §3 各接口 +- 测试账号、APIM 路由、CORS 等:mcp-server 后端 + +如发现本文档与 heicode 主线文档冲突,**以 heicode 主线为准**,并请回函通知 mcp-server 同步更新。 diff --git a/Docs/HEICODE_API_INTEGRATION.md b/Docs/HEICODE_API_INTEGRATION.md new file mode 100644 index 0000000..d062936 --- /dev/null +++ b/Docs/HEICODE_API_INTEGRATION.md @@ -0,0 +1,832 @@ +# Heicode Agent Manager API 对接文档 + +## 📋 目录 + +- [1. 概述](#1-概述) +- [2. 认证方式](#2-认证方式) +- [3. API 端点](#3-api-端点) +- [4. 数据模型](#4-数据模型) +- [5. 使用示例](#5-使用示例) +- [6. 错误处理](#6-错误处理) +- [7. 最佳实践](#7-最佳实践) + +--- + +## 1. 概述 + +### 1.1 服务信息 + +- **服务名称**: Agent Manager - Heicode Integration API +- **版本**: v2.0.0 (heicode-v2) +- **Base URL**: `http://agent-manager.taijiagnet.com` +- **API 前缀**: `/api/agnet` + +### 1.2 核心功能 + +- ✅ 多 Agent 编排部署 +- ✅ 预算控制和计费管理 +- ✅ 风险等级评估(low/medium/high) +- ✅ Vault 密钥集成 +- ✅ 实时日志和事件追踪 +- ✅ 资源监控和指标统计 +- ✅ 幂等性保证 + +### 1.3 架构说明 + +``` +┌─────────────┐ +│ Heicode │ +│ Platform │ +└──────┬──────┘ + │ HTTPS + Token Auth + ▼ +┌─────────────────────────────┐ +│ Agent Manager API │ +│ /api/agnet/* │ +└──────┬──────────────────────┘ + │ + ▼ +┌─────────────────────────────┐ +│ Kubernetes Cluster (AKS) │ +│ - Namespace 隔离 │ +│ - Pod 管理 │ +│ - ConfigMap/Secret │ +└─────────────────────────────┘ +``` + +--- + +## 2. 认证方式 + +### 2.1 Service Token 认证 + +所有 API 请求必须在 HTTP Header 中携带服务令牌: + +```http +Authorization: Bearer +``` + +### 2.2 必需的 HTTP Headers + +| Header | 必需 | 说明 | 示例 | +|--------|------|------|------| +| `Authorization` | ✅ | 服务令牌 | `Bearer sk_xxx` | +| `X-User-ID` | ✅ | 用户标识 | `user_12345` | +| `X-Binding-Scope` | ✅ | 绑定范围 | `workspace_abc` | +| `X-Correlation-ID` | ✅ | 请求追踪 ID | `req_xyz789` | +| `X-Idempotency-Key` | ⚪ | 幂等性键(推荐) | `idem_abc123` | +| `Content-Type` | ✅ | 内容类型 | `application/json` | + +### 2.3 获取 Service Token + +请联系系统管理员获取 `HEICODE_SERVICE_TOKEN`。 + +--- + +## 3. API 端点 + +### 3.1 健康检查 + +#### `GET /api/agnet/health` + +检查服务状态。 + +**请求示例**: +```bash +curl -X GET "http://agent-manager.taijiagnet.com/api/agnet/health" \ + -H "Authorization: Bearer sk_xxx" +``` + +**响应示例**: +```json +{ + "success": true, + "data": { + "status": "healthy", + "service": "agent-manager-agnet", + "version": "1.0.0", + "phase": "2-deployments" + } +} +``` + +--- + +### 3.2 创建部署 + +#### `POST /api/agnet/deployments` + +创建一个新的 Agent 部署。 + +**请求体**: +```json +{ + "orchestration_plan": "multi-agent-workflow", + "risk_level": "medium", + "approval_token": "optional_for_high_risk", + "budget": { + "max_usd": 100.0, + "alert_threshold_pct": 80 + }, + "billing_context": { + "provider": "newapi", + "default_model_id": "gpt-4", + "allowed_model_ids": ["gpt-4", "gpt-3.5-turbo"], + "secret_ref": "vault:heicode/model-gateway-key" + }, + "agents": [ + { + "role": "researcher", + "image": "agnettaiji.azurecr.io/ai-agents/search-agent:latest" + }, + { + "role": "writer", + "image": "agnettaiji.azurecr.io/ai-agents/doc-creator:latest" + } + ], + "resource_grants": [ + { + "type": "database", + "ref": "vault:heicode/db-credentials" + } + ] +} +``` + +**响应示例**: +```json +{ + "deployment_id": "dep_a1b2c3d4e5f6", + "status": "pending", + "agent_instances": [ + { + "agent_instance_id": "agi_123abc", + "role": "researcher", + "status": "pending", + "phase": null + }, + { + "agent_instance_id": "agi_456def", + "role": "writer", + "status": "pending", + "phase": null + } + ], + "created_at": "2026-05-12T10:30:00Z", + "estimated_ready_at": "2026-05-12T10:32:00Z" +} +``` + +--- + +### 3.3 列出部署 + +#### `GET /api/agnet/deployments` + +获取部署列表,支持过滤和分页。 + +**查询参数**: +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `user_id` | string | ⚪ | 按用户过滤 | +| `binding_scope` | string | ⚪ | 按绑定范围过滤 | +| `status` | string | ⚪ | 按状态过滤 (pending/running/stopped/failed) | +| `limit` | integer | ⚪ | 每页数量 (默认 50, 最大 200) | +| `cursor` | string | ⚪ | 分页游标 | + +**请求示例**: +```bash +curl -X GET "http://agent-manager.taijiagnet.com/api/agnet/deployments?user_id=user_123&status=running&limit=10" \ + -H "Authorization: Bearer sk_xxx" \ + -H "X-User-ID: user_123" \ + -H "X-Binding-Scope: workspace_abc" \ + -H "X-Correlation-ID: req_list_001" +``` + +**响应示例**: +```json +{ + "deployments": [ + { + "deployment_id": "dep_a1b2c3d4e5f6", + "status": "running", + "risk_level": "medium", + "budget": { + "max_usd": 100.0, + "consumed_usd": 23.5, + "remaining_usd": 76.5 + }, + "created_at": "2026-05-12T10:30:00Z", + "agent_instances_count": 2 + } + ], + "pagination": { + "next_cursor": null, + "has_more": false + } +} +``` + +--- + +### 3.4 获取部署详情 + +#### `GET /api/agnet/deployments/{deployment_id}` + +获取指定部署的详细信息。 + +**路径参数**: +- `deployment_id`: 部署 ID + +**请求示例**: +```bash +curl -X GET "http://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6" \ + -H "Authorization: Bearer sk_xxx" \ + -H "X-User-ID: user_123" \ + -H "X-Binding-Scope: workspace_abc" \ + -H "X-Correlation-ID: req_get_001" +``` + +**响应示例**: +```json +{ + "deployment_id": "dep_a1b2c3d4e5f6", + "user_id": "user_123", + "binding_scope": "workspace_abc", + "status": "running", + "phase": "executing", + "orchestration_plan": "multi-agent-workflow", + "risk_level": "medium", + "budget": { + "max_usd": 100.0, + "consumed_usd": 23.5, + "remaining_usd": 76.5 + }, + "billing_context": { + "provider": "newapi", + "default_model_id": "gpt-4", + "allowed_model_ids": ["gpt-4", "gpt-3.5-turbo"] + }, + "agent_instances": [ + { + "agent_instance_id": "agi_123abc", + "role": "researcher", + "status": "running", + "phase": "searching" + }, + { + "agent_instance_id": "agi_456def", + "role": "writer", + "status": "running", + "phase": "writing" + } + ], + "resource_grants": [ + { + "type": "database", + "ref": "vault:heicode/db-credentials" + } + ], + "created_at": "2026-05-12T10:30:00Z", + "updated_at": "2026-05-12T10:35:00Z" +} +``` + +--- + +### 3.5 停止部署 + +#### `POST /api/agnet/deployments/{deployment_id}/stop` + +停止一个正在运行的部署。 + +**路径参数**: +- `deployment_id`: 部署 ID + +**请求体**: +```json +{ + "reason": "User requested stop", + "approval_token": "optional_for_high_risk" +} +``` + +**请求示例**: +```bash +curl -X POST "http://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6/stop" \ + -H "Authorization: Bearer sk_xxx" \ + -H "X-User-ID: user_123" \ + -H "X-Binding-Scope: workspace_abc" \ + -H "X-Correlation-ID: req_stop_001" \ + -H "Content-Type: application/json" \ + -d '{ + "reason": "Task completed" + }' +``` + +**响应示例**: +```json +{ + "deployment_id": "dep_a1b2c3d4e5f6", + "status": "stopped", + "stopped_at": "2026-05-12T11:00:00Z" +} +``` + +--- + +### 3.6 获取部署日志 + +#### `GET /api/agnet/deployments/{deployment_id}/logs` + +获取部署的实时日志。 + +**路径参数**: +- `deployment_id`: 部署 ID + +**查询参数**: +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `agent_instance_id` | string | ⚪ | 按 Agent 实例过滤 | +| `since` | datetime | ⚪ | 起始时间 (ISO 8601) | +| `limit` | integer | ⚪ | 日志条数 (默认 100) | + +**请求示例**: +```bash +curl -X GET "http://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6/logs?limit=50" \ + -H "Authorization: Bearer sk_xxx" \ + -H "X-User-ID: user_123" \ + -H "X-Binding-Scope: workspace_abc" \ + -H "X-Correlation-ID: req_logs_001" +``` + +**响应示例**: +```json +{ + "deployment_id": "dep_a1b2c3d4e5f6", + "logs": [ + { + "timestamp": "2026-05-12T10:31:00Z", + "agent_instance_id": "agi_123abc", + "level": "info", + "message": "Starting search task...", + "source": "stdout" + }, + { + "timestamp": "2026-05-12T10:31:05Z", + "agent_instance_id": "agi_123abc", + "level": "info", + "message": "Found 10 relevant documents", + "source": "stdout" + } + ], + "pagination": { + "has_more": false + } +} +``` + +--- + +### 3.7 获取部署事件 + +#### `GET /api/agnet/deployments/{deployment_id}/events` + +获取部署的事件历史。 + +**路径参数**: +- `deployment_id`: 部署 ID + +**查询参数**: +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `event_type` | string | ⚪ | 事件类型过滤 | +| `since` | datetime | ⚪ | 起始时间 (ISO 8601) | +| `limit` | integer | ⚪ | 事件条数 (默认 100) | + +**事件类型**: +- `deployment.accepted` - 部署已接受 +- `deployment.started` - 部署已启动 +- `deployment.stopped` - 部署已停止 +- `deployment.failed` - 部署失败 +- `agent.started` - Agent 启动 +- `agent.completed` - Agent 完成 +- `budget.alert` - 预算告警 + +**请求示例**: +```bash +curl -X GET "http://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6/events" \ + -H "Authorization: Bearer sk_xxx" \ + -H "X-User-ID: user_123" \ + -H "X-Binding-Scope: workspace_abc" \ + -H "X-Correlation-ID: req_events_001" +``` + +**响应示例**: +```json +{ + "deployment_id": "dep_a1b2c3d4e5f6", + "events": [ + { + "event_id": "evt_abc123", + "event_type": "deployment.accepted", + "agent_instance_id": null, + "occurred_at": "2026-05-12T10:30:00Z", + "payload": { + "risk_level": "medium" + } + }, + { + "event_id": "evt_def456", + "event_type": "agent.started", + "agent_instance_id": "agi_123abc", + "occurred_at": "2026-05-12T10:31:00Z", + "payload": { + "role": "researcher" + } + } + ], + "pagination": { + "has_more": false + } +} +``` + +--- + +### 3.8 获取资源指标 + +#### `GET /api/agnet/deployments/{deployment_id}/metrics` + +获取部署的资源使用指标。 + +**路径参数**: +- `deployment_id`: 部署 ID + +**请求示例**: +```bash +curl -X GET "http://agent-manager.taijiagnet.com/api/agnet/deployments/dep_a1b2c3d4e5f6/metrics" \ + -H "Authorization: Bearer sk_xxx" \ + -H "X-User-ID: user_123" \ + -H "X-Binding-Scope: workspace_abc" \ + -H "X-Correlation-ID: req_metrics_001" +``` + +**响应示例**: +```json +{ + "deployment_id": "dep_a1b2c3d4e5f6", + "timestamp": "2026-05-12T10:35:00Z", + "agent_metrics": [ + { + "agent_instance_id": "agi_123abc", + "role": "researcher", + "status": "running", + "resources": { + "cpu_usage_cores": 0.25, + "memory_usage_mb": 256.0, + "network_rx_bytes": 1048576, + "network_tx_bytes": 524288 + }, + "uptime_seconds": 300 + }, + { + "agent_instance_id": "agi_456def", + "role": "writer", + "status": "running", + "resources": { + "cpu_usage_cores": 0.15, + "memory_usage_mb": 128.0, + "network_rx_bytes": 524288, + "network_tx_bytes": 262144 + }, + "uptime_seconds": 300 + } + ], + "total_resources": { + "cpu_usage_cores": 0.40, + "memory_usage_mb": 384.0, + "network_rx_bytes": 1572864, + "network_tx_bytes": 786432 + } +} +``` + +--- + +## 4. 数据模型 + +### 4.1 部署状态 (DeploymentStatus) + +| 状态 | 说明 | +|------|------| +| `pending` | 等待启动 | +| `running` | 运行中 | +| `stopped` | 已停止 | +| `failed` | 失败 | + +### 4.2 风险等级 (RiskLevel) + +| 等级 | 说明 | 审批要求 | +|------|------|----------| +| `low` | 低风险 | 无需审批 | +| `medium` | 中风险 | 无需审批 | +| `high` | 高风险 | 需要 approval_token | + +### 4.3 计费提供商 (BillingProvider) + +| 提供商 | 说明 | +|--------|------| +| `newapi` | Heicode NewAPI Gateway | +| `litellm` | LiteLLM Proxy | + +### 4.4 资源授权类型 (ResourceGrantType) + +| 类型 | 说明 | +|------|------| +| `database` | 数据库访问 | +| `storage` | 存储访问 | +| `api` | API 访问 | +| `custom` | 自定义资源 | + +--- + +## 5. 使用示例 + +### 5.1 完整工作流示例 + +```python +import requests +import time + +# 配置 +BASE_URL = "http://agent-manager.taijiagnet.com" +TOKEN = "sk_your_service_token" +USER_ID = "user_123" +BINDING_SCOPE = "workspace_abc" + +headers = { + "Authorization": f"Bearer {TOKEN}", + "X-User-ID": USER_ID, + "X-Binding-Scope": BINDING_SCOPE, + "X-Correlation-ID": f"req_{int(time.time())}", + "Content-Type": "application/json" +} + +# 1. 创建部署 +create_payload = { + "orchestration_plan": "research-and-write", + "risk_level": "medium", + "budget": { + "max_usd": 50.0, + "alert_threshold_pct": 80 + }, + "billing_context": { + "provider": "newapi", + "default_model_id": "gpt-4", + "allowed_model_ids": ["gpt-4", "gpt-3.5-turbo"], + "secret_ref": "vault:heicode/model-gateway-key" + }, + "agents": [ + { + "role": "researcher", + "image": "agnettaiji.azurecr.io/ai-agents/search-agent:latest" + }, + { + "role": "writer", + "image": "agnettaiji.azurecr.io/ai-agents/doc-creator:latest" + } + ], + "resource_grants": [] +} + +response = requests.post( + f"{BASE_URL}/api/agnet/deployments", + headers=headers, + json=create_payload +) +deployment = response.json() +deployment_id = deployment["deployment_id"] +print(f"✅ 部署创建成功: {deployment_id}") + +# 2. 等待部署就绪 +time.sleep(120) # 等待 2 分钟 + +# 3. 获取部署详情 +response = requests.get( + f"{BASE_URL}/api/agnet/deployments/{deployment_id}", + headers=headers +) +details = response.json() +print(f"📊 部署状态: {details['status']}") + +# 4. 获取实时日志 +response = requests.get( + f"{BASE_URL}/api/agnet/deployments/{deployment_id}/logs?limit=20", + headers=headers +) +logs = response.json() +print(f"📝 最新日志: {len(logs['logs'])} 条") + +# 5. 获取资源指标 +response = requests.get( + f"{BASE_URL}/api/agnet/deployments/{deployment_id}/metrics", + headers=headers +) +metrics = response.json() +print(f"💻 CPU 使用: {metrics['total_resources']['cpu_usage_cores']} cores") +print(f"💾 内存使用: {metrics['total_resources']['memory_usage_mb']} MB") + +# 6. 停止部署 +stop_payload = { + "reason": "Task completed successfully" +} +response = requests.post( + f"{BASE_URL}/api/agnet/deployments/{deployment_id}/stop", + headers=headers, + json=stop_payload +) +result = response.json() +print(f"🛑 部署已停止: {result['stopped_at']}") +``` + +### 5.2 幂等性示例 + +使用 `X-Idempotency-Key` 确保请求幂等性: + +```python +import uuid + +idempotency_key = f"idem_{uuid.uuid4().hex}" + +headers = { + "Authorization": f"Bearer {TOKEN}", + "X-User-ID": USER_ID, + "X-Binding-Scope": BINDING_SCOPE, + "X-Correlation-ID": f"req_{int(time.time())}", + "X-Idempotency-Key": idempotency_key, # 幂等性键 + "Content-Type": "application/json" +} + +# 第一次请求 +response1 = requests.post( + f"{BASE_URL}/api/agnet/deployments", + headers=headers, + json=create_payload +) + +# 重复请求(使用相同的 idempotency_key) +response2 = requests.post( + f"{BASE_URL}/api/agnet/deployments", + headers=headers, + json=create_payload +) + +# response1 和 response2 返回相同的结果 +assert response1.json()["deployment_id"] == response2.json()["deployment_id"] +``` + +--- + +## 6. 错误处理 + +### 6.1 错误响应格式 + +所有错误响应遵循统一格式: + +```json +{ + "success": false, + "error": { + "code": "ERROR_CODE", + "message": "Human-readable error message", + "request_id": "req_xyz789" + } +} +``` + +### 6.2 错误码列表 + +| HTTP 状态码 | 错误码 | 说明 | +|------------|--------|------| +| 401 | `UNAUTHORIZED` | 认证失败,Token 无效 | +| 403 | `FORBIDDEN` | 权限不足 | +| 404 | `DEPLOYMENT_NOT_FOUND` | 部署不存在 | +| 409 | `DEPLOYMENT_CONFLICT` | 部署状态冲突 | +| 422 | `MODEL_NOT_ALLOWED` | 模型不在允许列表中 | +| 422 | `POLICY_REJECTED` | 策略拒绝(如高风险需审批) | +| 422 | `VALIDATION_ERROR` | 请求参数验证失败 | +| 500 | `INTERNAL_ERROR` | 服务器内部错误 | + +### 6.3 错误处理示例 + +```python +try: + response = requests.post( + f"{BASE_URL}/api/agnet/deployments", + headers=headers, + json=create_payload + ) + response.raise_for_status() + deployment = response.json() + +except requests.exceptions.HTTPError as e: + error_data = e.response.json() + error_code = error_data["error"]["code"] + error_message = error_data["error"]["message"] + + if error_code == "MODEL_NOT_ALLOWED": + print(f"❌ 模型配置错误: {error_message}") + elif error_code == "POLICY_REJECTED": + print(f"❌ 需要审批: {error_message}") + else: + print(f"❌ 请求失败: {error_message}") +``` + +--- + +## 7. 最佳实践 + +### 7.1 认证和安全 + +✅ **推荐做法**: +- 将 Service Token 存储在环境变量或密钥管理系统中 +- 使用 HTTPS 进行所有 API 调用 +- 定期轮换 Service Token +- 使用 Vault 存储敏感配置(如 API Key) + +❌ **避免**: +- 在代码中硬编码 Token +- 在日志中打印 Token +- 在 URL 参数中传递敏感信息 + +### 7.2 幂等性 + +✅ **推荐做法**: +- 对所有创建操作使用 `X-Idempotency-Key` +- 使用 UUID 或时间戳生成唯一的幂等性键 +- 在网络不稳定时重试请求 + +### 7.3 预算控制 + +✅ **推荐做法**: +- 设置合理的 `max_usd` 预算上限 +- 设置 `alert_threshold_pct` 为 80-90% +- 定期检查 `consumed_usd` 和 `remaining_usd` +- 在预算告警时及时停止部署 + +### 7.4 日志和监控 + +✅ **推荐做法**: +- 使用 `X-Correlation-ID` 追踪请求链路 +- 定期轮询 `/logs` 和 `/events` 端点 +- 监控 `/metrics` 端点的资源使用情况 +- 保存审计日志用于问题排查 + +### 7.5 错误处理 + +✅ **推荐做法**: +- 实现指数退避重试机制 +- 区分可重试错误(5xx)和不可重试错误(4xx) +- 记录完整的错误上下文(request_id, correlation_id) +- 为高风险操作准备回滚方案 + +### 7.6 性能优化 + +✅ **推荐做法**: +- 使用分页参数避免一次性获取大量数据 +- 缓存不常变化的数据(如模板列表) +- 使用 `since` 参数增量获取日志和事件 +- 并发调用独立的 API 端点 + +--- + +## 8. 附录 + +### 8.1 支持的 Agent 镜像 + +| Agent 类型 | 镜像地址 | 说明 | +|-----------|---------|------| +| Search Agent | `agnettaiji.azurecr.io/ai-agents/search-agent:latest` | 搜索和信息检索 | +| Doc Creator | `agnettaiji.azurecr.io/ai-agents/doc-creator:latest` | 文档生成 | +| Code AI Agent | `agnettaiji.azurecr.io/ai-agents/code-ai-agent:latest` | 代码生成和 CI/CD | +| Ad Creator | `agnettaiji.azurecr.io/ai-agents/ad-creator:latest` | 广告创意生成 | +| Video Generator | `agnettaiji.azurecr.io/ai-agents/video-generator:latest` | 视频生成 | + +### 8.2 联系方式 + +- **技术支持**: support@taijiagnet.com +- **API 文档**: http://agent-manager.taijiagnet.com/docs +- **问题反馈**: https://github.com/your-org/agent-manager/issues + +### 8.3 更新日志 + +| 版本 | 日期 | 更新内容 | +|------|------|----------| +| v2.0.0 | 2026-05-12 | 初始版本,支持 Heicode 集成 | + +--- + +**文档版本**: v2.0.0 +**最后更新**: 2026-05-12 +**维护者**: Agent Manager Team diff --git a/Docs/Heicode-完整调用流程图.md b/Docs/Heicode-完整调用流程图.md new file mode 100644 index 0000000..b301cd2 --- /dev/null +++ b/Docs/Heicode-完整调用流程图.md @@ -0,0 +1,333 @@ +# Heicode 全栈完整调用流程图 + +**版本**: v1.0 +**生效日期**: 2026-05-05 +**目标**: 把 Heicode 整体架构里 5 个组件之间的真实调用关系画清楚,避免每次新功能上线时大家对边界理解不一致。 + +> 本文档是基于实际代码(heicode 仓库 + mcp-server 仓库)的核实结果,**不是设计文档**。 + +--- + +## 1. 5 个组件 + 各自定位 + +| 组件 | 物理形态 | 角色(按 Heicode 主线文档) | 对应代码 | +|---|---|---|---| +| **cc-haha 桌面客户端** | Tauri 桌面应用 + Bun CLI | "Heicode 客户端"(用户编程入口) | `heicode/cc-haha/` | +| **heicode web/default** | React + Rsbuild SPA | NewAPI 自带的管理 UI(普通用户进的那个) | `heicode/heicode/web/default/` | +| **heicode 后端 (Go)** | Gin + GORM | **NewAPI** —— 模型网关 + 计费 + 用户/Token/Group | `heicode/heicode/` | +| **mcp-server** | FastAPI (Python) | **Manager** —— 用户控制台 + 编排中枢 + 资源绑定 | `services/mcp-server/` | +| **agent-manager** | (待实现 12 接口) | **Agnet 平台** —— K8s 上跑子 Agent | 独立服务 | + +--- + +## 2. 已上线的调用关系(已核实) + +### 2.1 登录流程(已上线 + 实测通过) + +``` +┌─────────────────────────┐ +│ heicode web/default │ ← 用户在浏览器打开 https://heicode.../ +│ (React SPA) │ +└──────────┬──────────────┘ + │ + │ 1. POST /api/auth/login {email, password, role:"user"} + │ 跨域调用,VITE_HEICODE_AUTH_BASE_URL=https://apimtaiji.azure-api.net/api/mcp + ▼ +┌──────────────────────────────────┐ +│ mcp-server (Manager) │ +│ https://apimtaiji.azure-api.net │ +│ /api/mcp │ +└──────────┬───────────────────────┘ + │ + │ 2. 200 → {token, refreshToken, user{id, email, name, role, channelId}} + ▼ +┌─────────────────────────┐ +│ heicode web/default │ +│ 存 access/refresh 到 │ +│ localStorage │ +└──────────┬──────────────┘ + │ + │ 3. POST /api/user/session/from-agnet {access_token, refresh_token} + │ 同源调用 heicode 后端 + ▼ +┌──────────────────────────────────┐ +│ heicode 后端 (Go / NewAPI) │ +│ controller.HeicodeAgnetSession │ +│ Login │ +└──────────┬───────────────────────┘ + │ + │ 4. GET /api/auth/me (Bearer access_token) + │ 跨服务调用 mcp-server + ▼ +┌──────────────────────────────────┐ +│ mcp-server │ +│ (401 时 heicode 后端会 fallback │ +│ 先调 /api/auth/refresh) │ +└──────────┬───────────────────────┘ + │ + │ 5. 200 → {id, email, name, role, channelId, status} + ▼ +┌──────────────────────────────────┐ +│ heicode 后端 │ +│ - 按 email JIT 创建本地 user │ +│ - channelId → User.Group │ +│ - role 用本地白名单决定(不信任 │ +│ mcp-server 的 role 字段) │ +│ - 颁发 heicode session cookie │ +└──────────┬───────────────────────┘ + │ + │ 6. 200 → {success, data{id, role, group, ...}} + │ + Set-Cookie: heicode_session=... + ▼ +┌─────────────────────────┐ +│ heicode web/default │ +│ 显示已登录 │ +└─────────────────────────┘ +``` + +**关键事实**: +- heicode 前端**直接跨域**调 mcp-server 的登录接口(4 个:login/me/refresh/logout) +- heicode 后端**也调** mcp-server 的 `/me` 和 `/refresh`(用前端给的 token 验证) +- mcp-server 是**事实上的主认证源**,heicode 不维护自己独立的密码体系 +- heicode 后端**不信任** mcp-server 的 role 字段(防止外部身份提升),高权限角色由本地 `HEICODE_ROOT_EMAILS`/`HEICODE_ADMIN_EMAILS` 环境变量决定 + +### 2.2 cc-haha 桌面客户端是否调 mcp-server? + +**结论:当前不直接调**(已通过 grep 核实)。 + +cc-haha (`./bin/claude-haha`) 是独立的 CLI + Tauri 桌面工具,主要功能是 AI 编程辅助。它通过本地 server (`SERVER_PORT=3456 bun run src/server/index.ts`) 工作,**不直接对接 mcp-server**。 + +后续如果 cc-haha 要接入 Heicode 主流程(用户绑定资源 → 部署子 Agent),它会通过 heicode 后端中转,与 web/default 前端走同样的登录路径。 + +### 2.3 heicode 后端 → mcp-server 的依赖 + +代码位置:`controller/heicode_agnet_session.go` + +| heicode 调用 | mcp-server 端点 | 用途 | mcp-server 端能改吗? | +|---|---|---|---| +| `GET /api/auth/me` | ✅ 已上线 | JIT 同步用户身份 | **字段形态不能改**:id/email/name/role/channelId/status 都被 hardcode 解析 | +| `POST /api/auth/refresh` | ✅ 已上线 | access token 401 时 fallback | 字段形态不能改:data.token/refreshToken | + +### 2.4 heicode 前端 → mcp-server 的依赖 + +代码位置:`web/default/src/features/auth/api.ts` + +| heicode 前端调用 | mcp-server 端点 | 用途 | +|---|---|---| +| `POST /api/auth/login` | ✅ 已上线 | 用户登录 | +| `GET /api/auth/me` | ✅ 已上线 | 启动时校验 + 周期刷新 | +| `POST /api/auth/refresh` | ✅ 已上线 | access token 失效续期 | +| `POST /api/auth/logout` | ✅ 已上线 | 登出(与 heicode 后端 logout 双调用) | + +### 2.5 mcp-server → heicode 后端的依赖(**当前 0 调用**) + +mcp-server 现在**不调 heicode 后端**。 + +> **Heicode 团队 2026-05-07 修订(C 方案——两平台并存且各自有 product-level 模型网关)**: +> +> | 平台 | 组成 | 自家模型网关 | 服务对象 | +> |---|---|---|---| +> | **taijiagent** | mcp-server + agent-manager + LiteLLM | **LiteLLM**(产品级,不是 mcp-server 进程内细节)| 通过 mcp-server 直接部署 agent 的"原生 taijiagent 用户" | +> | **Heicode** | Heicode 客户端(桌面)+ heicode web(浏览器)+ heicode 后端(NewAPI)| **NewAPI**(产品级)| Heicode 客户端实时交互式 chat 用户 | +> +> 两个平台**共用 mcp-server 的账号体系**,**模型网关各自独立**。 +> +> **四条独立的模型调用路径**(完整真实场景): +> +> | # | 调用方 | 触发场景 | 走哪 | +> |---|---|---|---| +> | 1 | Heicode 客户端(桌面)+ heicode web 前端 | 用户实时交互式 chat / 写代码 | **Heicode NewAPI** | +> | 2 | 子 Agent Pod(**Heicode 用户部署的**) | 在 AKS 跑任务时调模型,`billing_context.provider="newapi"` | **Heicode NewAPI**(带 newapi_user_ref / newapi_group) | +> | 3 | 子 Agent Pod(**原生 taijiagent 用户部署的**) | 在 AKS 跑任务时调模型,`billing_context.provider` 默认或 = `"litellm"` | **taijiagent LiteLLM** | +> | 4 | mcp-server 进程内(embedding / 内部分类 / 系统功能) | mcp-server 自己内部使用 | **LiteLLM** | +> +> **关键事实**: +> - LiteLLM 同时承担两种角色 —— ① taijiagent 用户的 agent 模型网关(产品级)+ ② mcp-server 自己的内部工具 +> - NewAPI 同时承担两种角色 —— ① Heicode 客户端用户的实时交互网关 + ② Heicode 部署的 agent 计费网关 +> - 子 Agent 走哪条网关由 `billing_context.provider` 字段决定(mcp-server 在 `POST /api/agnet/deployments` payload 里设置) +> - 两个网关**互不替代**,**P4 不需要做迁移** +> +> **P4 NewAPI 解耦的真实任务**:mcp-server 在 Manager 控制台聚合费用展示时,对**Heicode 用户那部分**调用(路径 1 + 路径 2)从 NewAPI 拉元数据;对**纯 taijiagent 用户**那部分(路径 3)继续用自己的 LiteLLM 数据。聚合后展示给用户。属于**只读元数据查询 + 前端聚合**,不涉及网关迁移。 + +如果实施 P4 元数据查询,需要: +- mcp-server 新增 `app/heicode_client.py` 出站客户端 +- 调 heicode 后端的用户视角 API(`/api/user/self`、`/api/user/self/models`、`/api/log/self/stat` 等) +- LiteLLM **保留**,不替换 + +--- + +## 3. 待实现的调用关系(按 Heicode 主线 P5) + +### 3.1 部署子 Agent 流程(设计中,agent-manager 待实现 12 接口) + +``` +┌─────────────────────────┐ +│ heicode web/default 或 │ +│ cc-haha 客户端 │ ← 用户点"部署" +└──────────┬──────────────┘ + │ + │ 1. (经 heicode 后端中转 或 直接) POST /api/resources / /api/resource-grants + │ 定义资源绑定与授权 + ▼ +┌──────────────────────────────────┐ +│ mcp-server (Manager) │ +│ ✅ ResourceBinding/Grant 已上线 │ +└──────────┬───────────────────────┘ + │ + │ 2. POST /api/agnet/deployments + │ {orchestration_plan, user_context, billing_context, agent_runtime, resource_grants} + │ ❌ 出站客户端待实现 + ▼ +┌──────────────────────────────────┐ +│ agent-manager (Agnet 平台) │ +│ ❌ 12 个新接口全部待实现 │ +└──────────┬───────────────────────┘ + │ + │ 3. 创建 K8s Deployment + ServiceAccount + ConfigMap + │ ConfigMap 含 AGENT.md / resource_context / permission_manifest + │ ❌ Pod 启动行为待改造 + ▼ +┌──────────────────────────────────┐ +│ 子 Agent Pod (在 AKS) │ +│ ❌ 启动后通过 Workload Identity │ +│ 向 Vault 拉短期凭据 │ +└──────────┬───────────────────────┘ + │ + │ 4. (运行时) 调 Vault 拿 git token / cloud key + │ ❌ Vault 部署 + Workload Identity 配置待做 + ▼ +┌──────────────────────────────────┐ +│ Vault / OpenBao │ +│ ❌ 基础设施待部署 │ +└──────────────────────────────────┘ +``` + +### 3.2 子 Agent 模型调用(已工作 + 待对齐) + +``` +子 Agent 在 Pod 里跑 + │ + │ POST /v1/chat/completions + │ Authorization: Bearer + ▼ +heicode 后端 (NewAPI) + │ + │ 路由到具体 provider + ▼ +OpenAI / Claude / Gemini / Azure / Bedrock / ... +``` + +注:子 Agent 拿到的 heicode token 由 mcp-server 在创建 Deployment 时通过 `billing_context` 传给 agent-manager,agent-manager 注入 Pod env。**现在还没这个链路**。 + +--- + +## 4. mcp-server 角色总结 + +mcp-server 在 Heicode 全栈里**目前**承担 3 件事: + +| 角色 | 状态 | 接口 | +|---|---|---| +| **认证 IdP**(heicode 前端 + 后端的统一身份源) | ✅ 已上线 | `/api/auth/login` `/me` `/refresh` `/logout` | +| **资源绑定与授权**(用户绑定 Git/SK/云资源/项目文档;分配给子 Agent 角色)| ✅ 已上线 | `/api/resources/*` `/api/resource-grants/*` | +| **Agnet 平台编排器**(创建/查询/停止子 Agent 部署,转发给 agent-manager)| ❌ 待实现 | `/api/agnet/*`(12 个) | + +mcp-server **不**承担: +- ❌ 模型调用网关(heicode 后端 = NewAPI 干这事) +- ❌ AI 提供商接入(heicode 后端的 relay/channel 干这事) +- ❌ K8s 部署执行(agent-manager 干这事) +- ❌ 凭据托管(Vault 干这事,待部署) + +--- + +## 5. 现有 mcp-server 业务(不归 Heicode,但要知道避坑) + +mcp-server 还服务 **taiji 业务**:渠道后台、超管、用户中心、Agent 管理、PayPal 充值。这些不在 Heicode 范围,但代码共用。 + +| taiji 业务路由 | 状态 | 与 Heicode 的关系 | +|---|---|---| +| `/api/channel/*` 渠道后台 | 在用 | 不归 Heicode,**保持不动** | +| `/api/admin/*` 超管 | 在用 | 同上 | +| `/api/user/*` 用户中心 | 在用 | 同上 | +| `/api/agents/*` Agent 管理 | 在用 | 同上 | +| `/api/auth/*` 登录 | 在用 + Heicode 复用 | **被 Heicode 共用**,字段形态绝不能改 | +| `/api/billing/*` `/api/paypal/*` 计费 | 在用 | 不归 Heicode | + +--- + +## 6. CORS 现状与改进 + +**生产配置**:mcp-server 的 `cors_origins` 在 ConfigMap `taiji-config` 里**未设置**,fallback 到 `["*"]`。 + +**潜在问题**: +- mcp-server 设了 `allow_credentials=True` + `allow_origins=["*"]`,浏览器规范上**会拒绝**带 cookie 的跨域请求 +- 但 heicode 前端用 `Authorization: Bearer` 传 token,**不依赖 cookie**,实际可用 + +**改进建议**(不阻塞当前对接): +- 把 heicode 前端的真实部署域名加到 `CORS_ORIGINS`,去掉 `*` +- 例如:`CORS_ORIGINS=["https://heicode.xinghanlab.com","https://heicode-staging.xinghanlab.com"]` +- 与 heicode 团队确认其前端实际部署域名后配置 + +--- + +## 7. 已知技术债(**不阻塞**当前对接) + +| 项 | 说明 | 影响 | +|---|---|---| +| Channel 登录写审计日志 FK 错 | `audit_logs.user_id` FK 与 channel.id 不匹配 | 仅 channel 角色登录有 warning,user 角色无影响 | +| `cors_origins=["*"]` + `allow_credentials=True` | 与浏览器规范冲突 | 当前无影响(heicode 用 Bearer),未来要硬化 | +| LiteLLM vs NewAPI(heicode 后端)双轨 | 模型调用走 LiteLLM,未对接 heicode | 取决于产品决策,可能要迁移 | +| 死路由清理(之前已删 11 个)| `frontend_integration.py` 仍有部分历史代码 | 不影响功能 | + +--- + +## 8. 联调测试可执行步骤 + +### 步骤 1: 确认 mcp-server 4 个登录接口(已上线,无需操作) + +```bash +# 用真实账号登录 +curl -X POST https://apimtaiji.azure-api.net/api/mcp/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"55@55.com","password":"By@123456.","role":"user"}' + +# 拿 token 调 /me +curl https://apimtaiji.azure-api.net/api/mcp/api/auth/me \ + -H "Authorization: Bearer " +``` + +### 步骤 2: heicode 前端联调(heicode 团队执行) + +1. 部署 heicode web/default,配置 `VITE_HEICODE_AUTH_BASE_URL=https://apimtaiji.azure-api.net/api/mcp` +2. 在登录页输入 `55@55.com` / `By@123456.` +3. 预期:成功登录,浏览器 localStorage 有 `heicode_access_token` + `heicode_refresh_token` +4. 预期:heicode 后端 session cookie 也已颁发(通过 `from-agnet` 流程) + +### 步骤 3: 资源绑定联调(heicode 团队执行) + +1. 用步骤 2 的 access token 调 `POST /api/resources` 创建 git 绑定 +2. 调 `POST /api/resource-grants` 把 binding 授给 backend role +3. 预期:所有调用 200,DB 里有对应记录 + +### 步骤 4: 子 Agent 部署联调(**等 agent-manager 实现 12 接口后**) + +待 agent-manager 团队实现接口后再做。 + +--- + +## 9. 文档导航 + +| 文档 | 受众 | 内容 | +|---|---|---| +| **本文档** | 全员 | 整体调用关系、组件定位 | +| `Docs/Heicode-接口契约文档.md` | heicode 前后端开发 | 13 个 mcp-server 已上线接口的详细契约 | +| `Docs/Heicode-对接进度与待办.md` | PM / Lead | 进度盘点 + 待决策 | +| `Docs/Agent-Manager-Heicode对接需求文档.md` | agent-manager 团队 | 12 个新接口要求 + Pod 改造 + AKS 基础设施 | +| `Docs/Heicode-登录接口对接文档.md` | (旧版,已被超集化)| 登录单接口;建议转看接口契约文档 | + +--- + +## 10. 修订记录 + +| 版本 | 日期 | 变更 | +|---|---|---| +| v1.0 | 2026-05-05 | 初版:基于代码核实结果绘制 | diff --git a/Docs/Heicode-对接进度与待办.md b/Docs/Heicode-对接进度与待办.md index f54d5a8..680ed48 100644 --- a/Docs/Heicode-对接进度与待办.md +++ b/Docs/Heicode-对接进度与待办.md @@ -1,12 +1,33 @@ # Heicode ↔ mcp-server (Heicode Manager) 对接进度与待办 -**版本**: v1.0 -**生效日期**: 2026-05-05 +**版本**: v2.0 +**生效日期**: 2026-05-08 **对应 Heicode 主线**: `heicode.md` / `plan.md` / `heicode-runtime-auth-newapi-secret-design.md` / `integration/agnet-platform-request-contract.md` -**当前生产镜像**: `taiji.azurecr.io/mcp-server:heicode-p1-thin-20260505`(arm64,digest `sha256:af4ed24d3bdd640cf146e7ffef01f3bbf317a55b535fd7cce5a56c5e6d6446da`) +**当前生产镜像**: `taiji.azurecr.io/mcp-server:heicode-tasks-20260508`(arm64) + +**v2.0 重要变化**(vs v1.0): +- ✅ **P4 NewAPI 元数据透传 4 端点已上线**(待 B-1 admin token 配置即真跑) +- ✅ **P5 本地 stub 12 端点已上线**(mock 数据,cc-haha 可立即联调) +- ✅ **任务编排 5 端点已上线**(cc-haha 任务驾驶舱 / 工作台 直接切真) +- 当前已上线 mcp-server 端 **34 个 Heicode 相关接口** +- 全部端到端测试通过:登录 9/9 + P1 14/14 + P4 8/8 + P5 stub 20/20 + 任务编排 25/25 > 本文档汇总 mcp-server 侧已交付内容、未交付内容与原因,并明确每个待办项需要 Heicode 这边提供什么信息或决策。用于双方对齐进度、解锁阻塞。 +> 📍 **整体调用关系**见 [`Heicode-完整调用流程图.md`](./Heicode-完整调用流程图.md)。 + +## 0. Heicode 项目真实组成(核实于 2026-05-05) + +| 组件 | 物理形态 | 角色 | 与 mcp-server 关系 | +|---|---|---|---| +| `heicode/cc-haha/` | Tauri 桌面应用 + Bun CLI | "Heicode 客户端"(用户编程入口) | **当前不直接调** mcp-server | +| `heicode/heicode/web/default/` | React + Rsbuild SPA | NewAPI 自带管理 UI(用户进的那个) | ✅ 直接跨域调 mcp-server `/login` `/me` `/refresh` `/logout` | +| `heicode/heicode/` (Go 后端) | Gin + GORM | **NewAPI** —— 模型网关 + 计费 + 用户/Token/Group | ✅ 调 mcp-server `/me` `/refresh`(验证前端给的 token) | +| `heicode/website/` | Next.js | 极简市场页 | 与登录无关 | + +**结论**:mcp-server **是事实上的主认证源**。heicode 前后端都依赖 mcp-server 的 4 个登录接口;字段形态绝不能 breaking change。 + + --- ## 1. 已交付(生产可用 ✅) @@ -112,33 +133,110 @@ --- -### 2.3 P4 NewAPI 解耦 +### 2.3 P4 NewAPI 解耦(Manager 拉取 NewAPI 元数据) -**是什么**: Manager 调 NewAPI 拿模型/余额/用量/调用日志;NewAPI 后台不对普通用户开放;维护 user.id / channelId → NewAPI user/token/group 的映射。 +**Heicode 团队 2026-05-07 修订(C 方案 — 两平台并存,详见完整调用流程图 §2.5)**: -**当前状态:事实上已等价 ✓**: +模型调用实际有**四条独立路径**: -mcp-server 现在用 **LiteLLM** 做模型网关。LiteLLM 在 API 层面**等价于** NewAPI: -- ✅ 用户从 mcp-server 进,不直接进 LiteLLM 后台 -- ✅ 隐藏了渠道管理 / 价格 / 模型供应商配置 -- ✅ `User.channel_id` 已映射到 LiteLLM team_id -- ✅ `TenantModelKey` 已映射到 LiteLLM key(含 RPM/TPM/budget) -- ✅ 充值/余额/用量已透出(PayPal + audit_logs + AgentBillingRecord) +| # | 调用方 | 触发场景 | 走哪 | +|---|---|---|---| +| 1 | Heicode 客户端(桌面)+ heicode web 前端 | 用户实时交互式 chat / 写代码 | **Heicode NewAPI**(`code.xinghanlab.com`) | +| 2 | 子 Agent Pod(**Heicode 用户部署的**) | 任务执行时调模型,`billing_context.provider="newapi"` | **Heicode NewAPI** | +| 3 | 子 Agent Pod(**原生 taijiagent 用户部署的**) | 任务执行时调模型,`billing_context.provider` 默认或 `"litellm"` | **taijiagent LiteLLM** | +| 4 | mcp-server 进程内(embedding / 内部分类) | mcp-server 自身功能 | **LiteLLM** | -**为什么标"部分完成"**: +**核心结论**: +- LiteLLM 同时是 ① taijiagent 平台对其用户的**产品级**模型网关 + ② mcp-server 自己内部工具 +- NewAPI 同时是 ① Heicode 平台对 Heicode 客户端用户的**产品级**网关 + ② Heicode 部署的 agent 计费网关 +- 子 Agent 走哪条网关由 mcp-server 在 `POST /api/agnet/deployments` 的 `billing_context.provider` 字段决定 +- **LiteLLM 与 NewAPI 不冲突,不需要替代/迁移** -如果 Heicode 坚持**必须用 NewAPI 实体**(不是 LiteLLM 等价品),则需要: -- 把 LiteLLM 替换成 NewAPI(数周迁移) -- 重写计费/调用记录回调 -- 重新映射 channel/user/group/token +--- -**需要 Heicode 决策**: -- ❓ **必须 NewAPI 还是 LiteLLM 等价即可?** - - 如果"等价即可" → **本项视为已完成**,无后续工作 - - 如果"必须 NewAPI" → 需要排专项迁移,~2-3 周 -- ❓ 如果坚持 NewAPI,**NewAPI 实例如何部署**?连哪个数据库? +**P4 真实任务**:mcp-server 在控制台聚合费用展示时,把"路径 1+2 的 NewAPI 数据"和"路径 3 的 LiteLLM 数据"合并展示给用户。属于**只读元数据查询 + 前端聚合**。 -**推荐 owner**: 产品决策(先决策再排工程) +具体要拿的元数据(heicode NewAPI 已就绪的接口): + +| heicode 接口 | 用途 | +|---|---| +| `GET /api/user/self` | 用户基础信息 + 余额 | +| `GET /api/user/self/models` | 当前用户可用模型 | +| `GET /api/user/self/groups` | 用户分组 | +| `GET /api/log/self/stat` | 调用统计 | +| `GET /api/log/self` | 调用日志 | +| `GET /api/data/self` | 按日用量 | +| `GET /api/usage/token/` | token 维度用量(用 access token 调) | + +**当前状态**: mcp-server 还**没**调 NewAPI 拿元数据。元数据展示这块的 Manager UI 也还没建。 + +--- + +### 2.3.1 Heicode 团队对 4 个 ❓ 的答复(2026-05-07) + +> 决策已定,可直接开工,无需再往返。 + +| # | 问题 | **答复** | 理由 | +|---|---|---|---| +| ① | **服务凭据方案**(A pass-through / B service-token / C OAuth client) | **B:mcp-server 持有 NewAPI service token,按 user_id 维度筛选** | mcp-server 是后端服务(不是用户代理);service token 性能好、不依赖每用户凭据;A 路径在长会话场景下 token 续期复杂;C 路径需要 NewAPI 实装 OAuth provider,工作量太大且短期不需要。**Heicode 这边会用一个超级管理员 NewAPI access token 给 mcp-server 用**,token 通过 OpenBao/Vault 注入 mcp-server pod 环境变量。token 命名建议:`HEICODE_NEWAPI_SERVICE_TOKEN`。 | +| ② | **NewAPI base URL** | **`https://code.xinghanlab.com`**(生产 / 唯一对外入口) | 该域名走 Cloudflare → Azure VM → heicode 容器,已上线生产。mcp-server 出站走公网即可(Azure→Cloudflare→Azure 同 region);如未来需要内网,再加 Azure Private Endpoint。 | +| ③ | **Manager UI 范围**(哪些元数据要展示) | **第一版只展示 4 项**:① 余额(`GET /api/user/self` 的 quota 字段)② 当前可用模型清单(`/api/user/self/models`)③ 30 天用量汇总图(`/api/data/self`)④ 最近 50 条调用日志(`/api/log/self?limit=50`)。**详细统计、token 维度用量、分组管理留到 v2**。 | 与 heicode web 现有"用户中心"页对齐;mcp-server UI 不重复 NewAPI 自家功能,只做"聚合视图"差异化。 | +| ④ | **透传 vs 直连** | **透传**(mcp-server 包一层 `/api/user/heicode/*`) | 三个理由:① 前端只对接一个域(mcp-server),不引入 `code.xinghanlab.com` 第三方域 → 简化 CORS;② 鉴权审计统一在 mcp-server;③ 未来如果 NewAPI 切换内网地址,前端零改动。 | + +**配套约定**(Heicode 端落实): +- Heicode 这边创建一个专用的 `mcp-server-service` 用户(role = admin),生成一个永久 access token +- 该 token 走 OpenBao 注入 mcp-server pod 的 `HEICODE_NEWAPI_SERVICE_TOKEN` env +- mcp-server 调 NewAPI 时,在 user-context 路由(如 `/api/user/self`)上需要按 `New-Api-User: <真实用户 id>` 头切换身份;需要 mcp-server 提前拿到本地 `users.id`(即 from-agnet 流程里 syncLocalUserFromAgnet 返回的 id) + +**推荐 owner**: mcp-server(透传层 + heicode_client.py) + 基础设施(OpenBao token 注入) + +**工作量预估**(按 ① + ② + ③ + ④ 答复): +- mcp-server 加 `HEICODE_NEWAPI_SERVICE_TOKEN` env 读取 + heicode_client.py:~1 天 +- mcp-server 透出 4 个 `/api/user/heicode/*` 端点(balance / models / usage-30d / logs-recent-50):~1.5 天 +- Manager UI 一页"用量与费用" 接入:~1 天(前端) +- **总计 ~3.5 天**(比原 5 天压缩,因为 UI 只做 v1 范围) + +--- + +### 2.3.2 还需 Heicode 进一步澄清的 2 个细节(不阻塞主开工) + +Heicode 团队 2026-05-07 决策很完整,但落实到具体调用时还有 2 个二级问题没明示。**P4 主路径可以先开工**,这 2 个细节边做边对齐: + +#### 细节 ① mcp-server 怎么知道 heicode 那边的本地 `users.id`? + +Heicode 决策 ① 说:"mcp-server 调 NewAPI 时按 `New-Api-User: <真实用户 id>` 头切换身份"。 + +但 from-agnet 流程是 **heicode 后端调用 mcp-server**,mcp-server 从未收到 heicode 那边给该用户分配的本地 id。候选方案: + +| 方案 | 实现 | 取舍 | +|---|---|---| +| (a) heicode 在 from-agnet 后**回调** mcp-server 一个新接口 `POST /api/auth/heicode-link {mcp_user_id, heicode_user_id}` | 要双方都加接口 + 同步表 | 最干净,但工作量大 | +| (b) **mcp-server 用自己的 user_id(UUID)当 `New-Api-User`**,NewAPI 那边把这个 UUID 当外部身份键,反查本地用户 | NewAPI 这边 user 表加 `external_id` 字段;mcp-server 不需要知道 heicode local id | 最简单,但 NewAPI 数据模型要小改 | +| (c) mcp-server 调 NewAPI 时用 email 反查 heicode local id(多一次往返) | NewAPI 已有 `GET /api/user/?email=xxx` 即可(admin token) | 性能差,每次调 NewAPI 多一次查询 | + +**mcp-server 推荐 (b)**,因为: +- 已经在 from-agnet 流程里把 `mcp-server.user.id` 作为 `id` 字段返给 heicode 了(`/api/auth/me` 响应的 `data.id`) +- heicode 后端只需要在 `syncLocalUserFromAgnet` 里把它存到 `users.external_id` +- 之后 mcp-server 调 NewAPI 时,`New-Api-User: ` 就能让 NewAPI 反查到正确用户 + +❓ **请 Heicode 团队答复**:(a) / (b) / (c) 哪个? + +#### 细节 ② 子 Agent 部署时 mcp-server 怎么决定 `billing_context.provider`? + +Heicode 决策里隐含:"Heicode 用户" → `provider="newapi"`,"taijiagent 用户" → `provider="litellm"`。但 mcp-server 内的 User 表当前没有"哪个平台来的"标识。候选方案: + +| 方案 | 实现 | +|---|---| +| (a) User 表加 `billing_provider` 字段(`newapi`/`litellm`,默认 `litellm`),from-agnet 流程同步时设为 `newapi` | mcp-server 加字段 + from-agnet 时回填 | +| (b) 看 user 是否从 `syncLocalUserFromAgnet` 路径来 → 推断 newapi。但 mcp-server 这边不知道 heicode 是否调过自己 | 不可行,信息缺失 | +| (c) Channel 表加 `default_billing_provider`,channel-level 决策;user 沿用所属 channel 的默认 | 适合"按渠道分平台"的场景 | +| (d) 统一一个新字段 `User.platform` ∈ {`taijiagent`, `heicode`},billing provider 由 platform 派生 | 概念更清晰,但加字段 | + +**mcp-server 推荐 (a) 或 (d)**,最简单。 + +❓ **请 Heicode 团队答复**:(a) / (c) / (d) 哪个?或者 mcp-server 自己拍板? + +> 这两个细节即使没立刻答复也**不阻塞 P4 元数据查询主开工**。可以先用占位方案(mcp-server 用自己 user_id 当 `New-Api-User`,子 Agent 默认走 LiteLLM),跑通主流程后再按 Heicode 答复回填。 --- @@ -164,21 +262,46 @@ mcp-server 现在用 **LiteLLM** 做模型网关。LiteLLM 在 API 层面**等 --- -### 2.5 P5 — Manager 侧本地 stub 端点(**可以独立做**) +### 2.5 P5 — Manager 侧本地 stub 端点(✅ **2026-05-08 已上线**) **是什么**: 在 mcp-server 内挂载 12 个 `/api/agnet/*` 路由,返回 mock 数据,**用于前端联调**。 -**这个项目特殊**:可以**完全独立**做,不依赖 Heicode 任何方面。 +**完成情况**: +- 12 端点全部上线,字段形态严格按 `agnet-platform-request-contract.md` +- **20/20 端到端测试 PASS**(含 happy path + 5 错误码场景 + 幂等 + 跨用户隔离 + 业务回归) +- 镜像:`mcp-server:heicode-p5-stub-v2-20260508` +- 见 [接口契约 §5](./Heicode-接口契约文档.md) -**为什么没做**: 单纯**没人点单**。 +**实施过程发现的 1 个 bug 已修**:原 `reject_sensitive_keys` 子串匹配会把 `max_tokens` 误判为敏感字段(包含 "token")。已改为按契约 §8.3 仅扫 `metadata` / `constraints` / `audit` 三个字段。 -**需要 Heicode 提供**: 仅一个确认 — "现在做 stub 吗?" +**前端可立即用**:cc-haha 任务执行联调 / heicode web 展示部署状态都能基于真实路径 + 字段形态对接。 -**推荐 owner**: mcp-server +--- -**工作量预估**: ~1-2 天,~600-800 行(多数是字段校验和 mock 响应)。借 Dockerfile.thin **构建 30 秒**。 +### 2.7 任务编排 5 端点(✅ **2026-05-08 已上线**) -> 强烈建议优先做这个。**前端能立刻基于约定字段联调**,不必等 agent-manager。 +**是什么**:cc-haha 任务驾驶舱(切片 7)+ 工作台 UI 配套。用户输入想法 → Heicode 追问 → 全部答完 → 生成任务卡。 + +**端点**: +- `POST /api/user/tasks/intent` +- `GET /api/user/tasks` +- `GET /api/user/tasks/{id}` +- `POST /api/user/tasks/{id}/answer` +- `POST /api/user/tasks/{id}/messages` + +**完成情况**: +- 5 端点全部上线,字段形态严格对齐 `cc-haha/desktop/src/stores/heicodeTaskStore.ts` +- DB 表 `heicode_tasks` 已建(migration 026) +- **25/25 端到端测试 PASS**(含状态机、跨用户隔离、错误码 INVALID_OPTION/QUESTION_NOT_FOUND/NOT_FOUND/FORBIDDEN_SCOPE) +- 镜像:`mcp-server:heicode-tasks-20260508` +- 见 [接口契约 §6](./Heicode-接口契约文档.md) + +**MVP 编排器为确定性模板**(不依赖 LLM): +- 初始 followups:scope(mvp/polish/prod-高风险)+ tech(modern_web/py_backend/let_heicode) +- 全答完 → 用 answers 填模板生成 TaskCard +- 后续可加 LLM 增强(用 LiteLLM 生成更智能 followups),**前端 0 影响** + +**cc-haha 可立即从 mock 切真**:把 `heicodeTaskStore.ts` 里的 seeded 数据替换成 5 个端点调用即可。 --- @@ -294,3 +417,916 @@ mcp-server 现有代码层面仍存在: - mcp-server 后端可按 `X-Request-Id` 反查日志和审计 如需 Heicode 这边对未实现项做决策(见 §2 各项末尾的 ❓),请直接答复或在 Heicode 仓库新建 issue。 + +--- + +## 7. Heicode 团队答复(2026-05-07) + +> 回复人:Heicode 客户端 / Manager 侧 +> 范围:本文档 §2 列出的 18 项 ❓ 决策点 + §2.4 P4 NewAPI vs LiteLLM 表述澄清 +> 状态:本节是答复,**不是规范**;规范以 Heicode 仓库 `docs/` 下原始设计为准 + +### 7.1 立即可定的(10 条) + +| # | 原问题位置 | 答复 | +|---|---|---| +| 1 | §2.1 Secret Provider 选型 | **HashiCorp Vault**。理由:Heicode 上游 `heicode.md §六` 把它列在第一位,K8s Auth/TTL/Policy 能力最全,与 §五的"密钥不能进入客户端,由 Manager 统一托管"路径最契合 | +| 2 | §2.1 部署位置 | **AKS 内独立 namespace `secret-store`**。与 Agnet 同集群、内网通路;不暴露公网(参见 `deployment/azure-production-deploy-guardrails.md`) | +| 3 | §2.1 凭据来源 | **优先 OAuth / GitHub App,前端表单作为兜底**。GitHub App 路径符合 §1.2 P1 `secret_ref` 不接收明文的安全红线 | +| 4 | §2.1 secret_ref 路径规范 | **OK**。`vault://secret/users/{user_id}/bindings/{binding_id}/resources/{resource_id}` 与 `agnet-platform-request-contract` 中 `permission_manifest.resource_grants[].secret_ref` 示例完全一致 | +| 5 | §2.1 轮换策略 | **Vault 原生 TTL + Pod 启动时 K8s Auth 自动续期**。无需 Manager 显式触发轮换 | +| 6 | §2.2 K8s ServiceAccount 命名 | **`sa-{role}-{user_id_short_hash}`**(与 `Agent-Manager-Heicode对接需求文档.md §4.1` 一致)。短哈希取 user_id sha1 前 8 位 | +| 7 | §2.4 NewAPI vs LiteLLM | **两个并行平台各有网关,不替代不冲突**。LiteLLM 是 taijiagent 平台对其用户的产品级模型网关;NewAPI 是 Heicode 平台对其用户的产品级模型网关;两边共用 mcp-server 账号。详见 §7.3 | +| 8 | §2.5 是否做 P5 本地 stub | **立即做**。这是 Heicode 客户端联调任务驾驶舱主流程的前置;本文档自己也建议优先(详见 §7.5 第 2 项已升级为阻塞)| +| 9 | Agent-Manager 文档 §2.1 服务令牌方案 | **(A) Pre-shared bearer 起步,后期升 (C) Workload Identity**。与 mcp-server 自己的建议一致 | +| 10 | §2.2 子 Agent 镜像 entrypoint 改造负责人 | **agent-manager 团队**。Heicode 不出 Agent 镜像;Pod 启动逻辑、Vault 拉取流程均归 agent-manager | + +### 7.2 需团队/基础设施侧讨论后再定(8 条,先记账) + +| # | 原问题位置 | 当前判断 | 等谁 | +|---|---|---|---| +| 11 | §2.2 AKS Workload Identity 启用 | 必须启用(Heicode 安全设计依赖) | 基础设施团队排期 | +| 12 | §2.2 dev/staging/prod 集群分隔 | **至少 staging + prod**;dev 复用 staging | 基础设施团队 | +| 13 | §2.4 如选 NewAPI 实体如何部署 | 见 §7.3 —— 当前不强制迁移 | — | +| 14 | §2.5/§2.6 agent-manager 12 接口时间表 | 参考其文档 §7,预计 3-4 周闭环 | agent-manager 团队回复 | +| 15 | §2.5 Agnet 平台 base URL + 服务令牌 secret_ref | 待 staging 部署落地 | agent-manager 团队 | +| 16 | §2.6 联调环境地址 | 建议 `staging-agnet.xinghanlab.com` / `agnet.xinghanlab.com` | agent-manager 团队 | +| 17 | §2.6 agent-manager 团队对接负责人 | — | 项目管理 | +| 18 | §2.6 agent-manager 开发节奏 | 按其 §7 phase | agent-manager 团队 | + +### 7.3 §2.4 P4 表述澄清:C 方案 — 两个并行平台 + 子 Agent 网关由 billing_context 决定 + +> ⚠️ Heicode 团队 2026-05-07 三次修订(最终版)。本节经历的版本: +> - v1(已撤回):"LiteLLM 是 mcp-server 进程内细节" — 否定了 LiteLLM 的产品级地位 +> - v2(已撤回):mcp-server 团队 §2.3 改写为 "子 Agent 全部走 NewAPI" — 忽略了原生 taijiagent 用户场景 +> - v3(已撤回):Heicode 反向修订为 "子 Agent 全部走 LiteLLM" — 忽略了 Heicode 用户部署 agent 的场景 +> - **v4(本版,最终)**:C 方案 — 子 Agent 网关由 `billing_context.provider` 字段决定,两条路径并存。**已被 mcp-server 团队最新 §2.3 采用。** + +**架构(C 方案)**: + +| 平台 | 组成 | 自家产品级模型网关 | +|---|---|---| +| **taijiagent** | mcp-server + agent-manager + LiteLLM | **LiteLLM** | +| **Heicode** | Heicode 客户端(桌面)+ heicode web(浏览器)+ heicode 后端(NewAPI)| **NewAPI** | + +两个平台**共用 mcp-server 的账号体系**,**模型网关各自独立**。 + +**四条独立的模型调用路径**: + +``` +[路径 1] Heicode 客户端(桌面)+ heicode web(浏览器)用户实时交互式 chat + → Heicode NewAPI (code.xinghanlab.com) → 上游 LLM + +[路径 2] 子 Agent Pod (Heicode 用户部署的,billing_context.provider="newapi") + → Heicode NewAPI (带 newapi_user_ref / newapi_group) → 上游 LLM + +[路径 3] 子 Agent Pod (原生 taijiagent 用户部署的,billing_context.provider 默认或 "litellm") + → taijiagent LiteLLM → 上游 LLM + +[路径 4] mcp-server 进程内功能 (embedding / 内部分类) + → LiteLLM (mcp-server 自用) +``` + +**关键事实**: +- **LiteLLM 同时承担两种角色**:① taijiagent 平台对其用户的产品级模型网关(路径 3)+ ② mcp-server 自身内部工具(路径 4) +- **NewAPI 同时承担两种角色**:① Heicode 平台对 Heicode 客户端 / heicode web 用户的产品级网关(路径 1)+ ② Heicode 用户部署的 agent 计费网关(路径 2) +- 子 Agent 走哪条网关由 mcp-server 在 `POST /api/agnet/deployments` 的 `billing_context.provider` 字段决定 +- 路径 2 和路径 3 共用同一套 agent-manager + AKS Pod 基础设施,**只是计费网关不同** + +**P4 真实任务**(mcp-server 控制台聚合费用面板): + +| 数据来源 | 适用场景 | 数据获取 | +|---|---|---| +| mcp-server 自己的 LiteLLM 计费记录 | 路径 3 + 路径 4 | mcp-server 直接读自己 DB | +| Heicode NewAPI 元数据 | 路径 1 + 路径 2 | mcp-server 通过 `app/heicode_client.py` 调 `code.xinghanlab.com` 的只读接口 | + +聚合后给用户展示统一费用面板。详细 4 个 ❓ 答复见 **§2.3.1**。 + +**P4 不需要做的**:替换 / 迁移 LiteLLM。 + +理由参考: +- 上游 `heicode.md §八`:NewAPI 是 "**Heicode 平台**的内部模型网关与计费服务,后台不对普通用户开放" —— **Heicode 平台范围内** +- 上游 `vision-heicode-full-stack-agentic-dev.md` 把 Heicode 定位为"全栈 agentic dev"产品,借用 taijiagent 的 mcp-server(认证)+ agent-manager(执行);**不要求 LiteLLM 改成 NewAPI** +- LiteLLM 在 Heicode 上游设计文档中**完全不出现** —— 是 taijiagent 平台的产品级网关,不是 Heicode 的组件 +- `agnet-platform-request-contract.md` `billing_context.provider="newapi"` 是 Heicode **预设接入方式**;taijiagent 平台目前不接受这个 provider,agent 全走 LiteLLM + +### 7.4 Heicode 客户端当前对接状态(2026-05-08 修订) + +> 术语澄清:本文中的 **"Heicode 客户端"** 即仓库内 `cc-haha/desktop/` +> 目录构建出的 Tauri 桌面端。`cc-haha` 是历史 fork 上游的目录名,对外 +> 措辞统一用 **Heicode 客户端**,下文文件路径中的 `cc-haha/...` 仍保留 +> 因为是物理目录。 + +> ⚠️ **2026-05-08 重要修订**:在 `docs/product-package/`(14 份产品文档落地)后, +> 客户端 / Manager 边界**重新划线**。08-client-guide.md §1-7 明确 **客户端不承担** +> 资源绑定 / 权限分配 / 账号与安全 / Agnet 部署 — 这些**全部移到 Manager**。 +> Heicode 客户端在切片 5(commit `4827682`)下线了之前实装的 Resources UI 全套。 + +#### 登录链路(不变,✅ 已验证) + +``` +1. POST apimtaiji.azure-api.net/api/mcp/api/auth/login (mcp-server) + Body: {email, password, role: "user"} + → 200 {token, refreshToken, user{id, email, role, channelId}} + +2. POST code.xinghanlab.com/api/user/session/from-agnet (heicode 后端) + Body: {access_token, refresh_token} + → 200 + Set-Cookie: session + +3. GET code.xinghanlab.com/heicode/oauth/authorize?... (heicode 后端) + Headers: Cookie + New-Api-User + → 302 Location: ...?token=sk-XXX + +4. NewAPI sk- token 用于后续所有 /v1/messages、/v1/models 调用 +``` + +测试号 `55@55.com / By@123456.` 端到端实测通过;channelId `6e6fc470-...` → +`/v1/models` 返回 28 个真实模型。 + +#### Heicode 客户端当前已实装的产品级 surface(按产品包 wireframe) + +| Surface | wireframe 章节 | 实装状态 | 数据通道 | +|---|---|---|---| +| 极简登录页(品牌 + 邮箱+密码 + tagline) | §1 | ✅ commit `f08119f` | Path A(已用真接口)| +| 高危审批弹窗(任务 / 操作 / 资源 / 角色 / 影响 / 凭证 / Heicode 建议 / 三按钮)| §9 | ✅ commit `f08119f` | **mock**(`window.__heicodeMockApproval()` 触发) | +| 任务驾驶舱首页(intent prompt + 最近任务 + Manager 辅助提示)| §2 | ✅ commit `a268079` | **mock**(seeded 2 个任务) | +| 任务工作台(对话流 + 追问 chip + 任务卡 panel) | §3 | ✅ commit `a268079` | **mock**(seeded 对话 + 已选答案) | +| token 持久化(mcpAuth schema + 24h refresh)| — | ✅ 保留 | Path A 登录后自动写入 | +| 高级黑主题 + H 电路 logo + Win 自绘窗框 | 02 / 11 §1 | ✅ | — | + +#### Heicode 客户端**已下线**(按产品包 08 §1-7 客户端边界) + +| Surface | 状态 | 原因 | +|---|---|---| +| 资源绑定 CRUD 页面(之前 9 接口对接 + Modals)| ❌ 下线(commit `4827682`)| 产品包 08-client-guide.md 客户端不承担资源绑定 | +| Resource Grants UI | ❌ 下线 | 同上 | +| 模型 provider 配置页面 | ❌ 下线 | 产品包 08 客户端不出现"模型提供方配置" | +| ClawdRouter 备选登录 | ❌ 下线 | 产品包"登录目标只有 Heicode" | +| `/api/heicode-resources/*` 本地代理 | ❌ 下线 | 客户端不再调 P1 接口 | +| `cc-haha-provider`/`env`/`original-settings` 三级 fallback | ❌ 下线 | 产品包要求强制走 Heicode 登录 | + +> 资源绑定 / 权限 / 部署 等 surface 应由 **heicode web (`code.xinghanlab.com`)** +> 承担,Heicode 客户端通过任务卡里的 "去 Manager 准备" 按钮跳转过去。 + +#### Heicode 客户端等 mcp-server 接口的清单 + +| 等的接口 | 用途 | mock 现状 | 阻塞优先级 | +|---|---|---|---| +| 任务编排(intent → followups → manifest → status)| 把任务驾驶舱 mock 换成真数据 | seed 2 个任务 + 浮于内存 | 🔴 高 — UI 已搭好等接口 | +| 高危审批分发(推送通道)| 把 `__heicodeMockApproval` 换成真订阅 | 仅 DevTools 触发 | 🔴 高 — UI 已搭好等接口 | +| `/api/user/heicode/*` 元数据(§2.3.1)| 客户端右上角顶栏显示余额 / 模型 | — | 🟡 中 — 产品包没强制要求客户端展示 | +| P5 Agnet 12 接口(stub) | 任务执行反馈 + 交付结果面板 | 未做(切片 8/9 待启动)| 🟡 中 — 等 stub 后做 | + +### 7.5 反向请求 mcp-server 团队配合的事项(2026-05-08 修订) + +1. **建议本文档第 §2.4 节按 §7.3 改写**,避免"等价于"导致后续团队误解为替代关系 +2. 🔴 **P5 本地 stub —— 现在已升级为「阻塞 Heicode 客户端联调」**:Heicode 客户端在切片 7 把任务驾驶舱 + 工作台 UI 全做完了,现在卡在 mock 数据阶段。**stub 出来当天就能联调全主流程**(intent → 追问 → 任务卡 → manager 跳转 → 状态轮询)。原本 1-2 天交付;建议立刻排期。 +3. **APIM 网关 CORS / IP 白名单**:Heicode 客户端通过本地 Bun server 调 `apimtaiji.azure-api.net`,请确认无 IP 白名单限制(当前实测从北京 / Azure 香港均可达) +4. **`channelId` 双重含义建议二选一**或加字段名区分: + - "渠道身份" 用 `channelId` + - "NewAPI user/group 映射" 用 `newapi_user_ref` / `newapi_group`(与 `agnet-platform-request-contract` 一致) + +5. 🆕 **任务编排 API 契约设计** —— 产品包 14 份文档**没有对应技术契约**,但客户端已按 wireframe §2-§3 把字段全设计好。请 mcp-server 团队**确认这套字段形态**或**提供你们的接口契约**让客户端对齐,否则等接口出来 UI 字段对不上要重做。客户端当前用的字段(见 `cc-haha/desktop/src/stores/heicodeTaskStore.ts`): + + ```ts + type HeicodeTask = { + id: string + name: string + status: 'draft' | 'configuring' | 'running' + | 'awaiting_approval' | 'completed' | 'failed' | 'paused' + status_caption?: string // 状态副标题,"后端实现中" / "等待生产部署审批" + created_at: number; updated_at: number + intent: string // 用户原始想法 + thread: ChatTurn[] // 对话流(用户 + Heicode) + card?: TaskCard // 任务卡(可空,等 Heicode 整理出来) + } + + type ChatTurn = + | { kind: 'user'; text: string; at: number } + | { kind: 'heicode'; text: string; at: number; followups?: FollowupQuestion[] } + + type FollowupQuestion = { + id: string; question: string + options: Array<{ id: string; label: string; risk?: 'high-risk' }> + answer?: string // 用户选了哪个 option id + } + + type TaskCard = { + goal: string // "做一个面向小团队的任务协作 SaaS。" + scope: string[] // bullet 列表 + generated_artifacts: string[] // ["产品说明", "原型描述", ...] + manager_actions: Array<{ label: string; deeplink: string }> + } + ``` + + 预期的 API 形态(建议 mcp-server 实装): + - `POST /api/user/tasks/intent` body `{ intent }` → 返回新建 Task(含初始 followups) + - `GET /api/user/tasks` → 返回用户的任务列表 + - `GET /api/user/tasks/{id}` → 返回单个 Task + - `POST /api/user/tasks/{id}/answer` body `{ question_id, option_id }` → 更新 followup 答案,可能触发新一轮 Heicode 追问 + - `POST /api/user/tasks/{id}/messages` body `{ text }` → 用户继续追加要求 + +6. 🆕 **高危审批分发机制** —— Heicode 客户端已实装 ApprovalDialog(wireframe §9 全字段对齐),现在 mock 用 `window.__heicodeMockApproval()` 触发。请 mcp-server 团队**选定推送通道**: + + | 方案 | 优点 | 缺点 | + |---|---|---| + | (A) SSE — `GET /api/user/approvals/stream` | 实时;浏览器/桌面通用 | mcp-server 要管 SSE 心跳 | + | (B) 短轮询 — `GET /api/user/approvals/pending`(5-15s) | 简单;无状态 | 延迟;高频负载 | + | (C) WebSocket | 双向;可顺便推任务状态 | 协议更重 | + + 建议 **(A) SSE** 起步(mcp-server 已经在用 FastAPI,加 SSE 简单)。 + 推送的 ApprovalRequest 字段对齐客户端类型:见 `cc-haha/desktop/src/stores/approvalStore.ts:ApprovalRequest`。 + +7. 🆕 **确认资源绑定 UI 由 Manager 承担**:Heicode 客户端在切片 5 已下线全套 P1 资源绑定 UI(08-client-guide.md §1-7 客户端不承担资源绑定)。Manager (`code.xinghanlab.com`) web 前端应该接 mcp-server 的 9 个 P1 接口实装资源绑定页。如果当前 heicode web 前端**没接** P1 接口,请 Manager 团队补上 —— 否则用户从客户端任务卡点 "去 Manager 准备" 按钮过去后会 404。 + +### 7.6 联系方式 + +- Heicode 客户端 / Manager 侧 commit 历史见 `chenchen/heicode-win` 仓库 main 分支 +- 关键 commit: + - `86bad23` feat(login): align desktop credentials login with upstream Heicode design + - `4827682` refactor(client): retire client-side resources UI per new product spec + - `f08119f` feat(client): slice 6 — minimal login per spec + high-risk approval dialog + - `a268079` feat(client): slice 7 — task driving cabin (intent → followups → task card) + +--- + +## 7.7 Heicode 团队对 v2.0(2026-05-08)的答复 + +> 回复人:Heicode 客户端 / Manager 侧 +> 范围:本文档 §B 列出的 5 项待办 + §2.3.2 的 2 个细节 ❓ +> 状态:本节是答复,**不是规范** + +先感谢 mcp-server 团队的爆发式交付——一天上线 21 个新接口(§4 + §5 + §6),字段对齐 cc-haha 既有代码(heicodeTaskStore.ts),完全消除了我们 §7.5 列出的两条核心阻塞项(任务编排契约 + P5 stub)。👍 + +### 7.7.1 §2.3.2 细节答复 + +| 细节 | 选 | 理由 | +|---|---|---| +| **① mcp-server 怎么知道 heicode 本地 user_id?** | **(b)** mcp-server 用自己的 user_id(UUID)当 `New-Api-User`;NewAPI 表加 `external_id` 字段反查 | NewAPI 是我们自家 fork(heicode go 后端),加一行 migration + 一个 `WHERE external_id = ?` 查询,比方案 (a) 双向回调和 (c) email 反查都更干净。Heicode 后端会在 `syncLocalUserFromAgnet` 里把 `mcp_user_id` 存到 `users.external_id` 字段。 | +| **② 子 Agent `billing_context.provider` 怎么决定?** | **(a)** mcp-server User 表加 `billing_provider` ∈ {`newapi`, `litellm`},默认 `litellm`;from-agnet 同步时设 `newapi` | 概念清晰,加字段成本低;(d) `User.platform` 也行但容易扩散。后续如出现"按渠道分平台"的需求再升级到 (c)。 | + +> 这两个改动 **NewAPI 侧由 Heicode 后端团队负责**(chenchen/heicode-win 的 `heicode/` Go 仓库),mcp-server 只需要按选定方案落地自己那一侧。 + +### 7.7.2 §B 待办进度 + +| # | Heicode 侧状态 | 备注 | +|---|---|---| +| **B-1** NewAPI service token | ✅ **已交付 + P4 真跑实测通过(2026-05-08)**。token 已交付 mcp-server,**已 rotate**(旧 32 字符 hex 已失效),DB 持有最新值 | 见下方 7.7.2.1 | +| **B-2** §2.3.2 答复 | ✅ 见上节 7.7.1 | — | + +#### 7.7.2.1 B-1 实际交付明细 + +| 字段 | 值 | +|---|---| +| NewAPI 用户 id | **`26`**(即 `HEICODE_NEWAPI_ADMIN_USER_ID`)| +| username | `mcp-server` | +| role | 10(管理员)| +| status | 1(已启用)| +| access_token | 32 字符 hex(即 `HEICODE_NEWAPI_SERVICE_TOKEN`)— 见下方注 | + +> **🔐 Token 提取约定(mcp-server AI 看这里)**: +> 实际 access_token 字符串放在本仓库的 [`Docs/heicode-svc-token.txt`](./heicode-svc-token.txt) —— **这个文件已被 `.gitignore` 排除**(验证:`git check-ignore -v Docs/heicode-svc-token.txt` 命中),仅在 chenchen 本机存在;本文档不写入明文值。 +> +> mcp-server 团队 AI 操作流程: +> 1. `cat Docs/heicode-svc-token.txt` 读取 32 字符 hex +> 2. 通过 K8s secret 注入 mcp-server pod env:`HEICODE_NEWAPI_SERVICE_TOKEN=<那 32 字符>` + `HEICODE_NEWAPI_ADMIN_USER_ID=26` +> 3. `kubectl rollout restart deployment/mcp-server` 让新 env 生效 +> 4. 注入完成后**告诉 chenchen 方"已收到,请删除 Docs/heicode-svc-token.txt 并 rotate token"** +> 5. chenchen 方收到 ack 后:① 删除 `Docs/heicode-svc-token.txt`;② 进 NewAPI `/profile`(mcp-server 用户)重新生成 token,触发旧 token 失效;③ 把新 token 同样通过本文件路径再传一次(如果还需要) +> +> ⚠️ 这是**一次性**临时通道,不是稳态机制。完成 P4 真跑后请把这套流程换成 OpenBao secret + K8s External Secrets Operator 拉取,参见 §2.1。 + +> **🔁 Rotate 状态(2026-05-08,mcp-server 团队 P4 真跑实测通过后)**: +> 1. ✅ Heicode 方删除 `Docs/heicode-svc-token.txt` + 本机 `D:\heicode-svc-token.txt` 副本 +> 2. ✅ DB 中 `users.id=26` 的 access_token 已 rotate(新 32 字符 hex 写入),**先前那个交付的 token 立即失效** +> 3. ✅ VM `/tmp/heicode-svc-token.txt` 临时副本删除 +> +> 当前 DB 持有的是 rotation 后的新 token,**没有任何文件副本**。下次 mcp-server 需要时再走同一通道(参见上面 5 步流程)传新值。 + +**端到端实测**(mcp-server 团队会用到的 admin 端点,本地从 VM 上 curl 测过): + +| 端点 | 结果 | +|---|---| +| `GET /api/user/self` | ✅ 200, role=10 | +| `GET /api/user/{id}`(普通用户)| ✅ | +| `GET /api/user/?p=0&page_size=5`(用户列表)| ✅ | +| `GET /api/user/search?keyword=...`(admin 搜索)| ✅ | + +⚠️ **已知约束**:admin (role=10) 读不了 root (role=100) 用户(heicode 仓库 `controller/user.go:280` 的 `myRole <= user.Role && myRole != RoleRootUser` 限制)。mcp-server 只查普通用户(role=1)和 admin(role=10),不受影响。如有读 root 的需要请告知,可以把 service 账号升 root,但当前默认走 admin。 + +**调用约定**(auth 中间件 [middleware/auth.go:36-122](https://gitee.ath.cx:3000/chenchen/heicode-win/src/branch/main/heicode/middleware/auth.go)):每次请求**两个 header 都必填** + +``` +Authorization: Bearer +New-Api-User: 26 +``` + +`New-Api-User` 必须等于 token 持有者的 user.id(即 26),中间件强制校验 `id != apiUserId` mismatch。要查别的用户的数据,路径参数填别人 id 即可(如 `/api/user/2`),但 New-Api-User 头始终是调用者自己(26)。 +| **B-3** heicode web 接 P1 资源 UI | 🟡 待协调。需要确认 `code.xinghanlab.com` 当前前端是 `heicode/web/default/` (NewAPI 自带 React) 还是另起新前端项目;若沿用现有前端,需排期接入 9 个 P1 接口 | 不阻塞客户端 | +| **B-4** agent-manager 12 真接口 | 🟢 待推进。需要拿到 agent-manager 团队对接负责人和排期 | mcp-server P5 stub 已上线,前端可基于 stub 全主流程联调,所以这条不阻塞 | +| **B-5** cc-haha 任务驾驶舱切真 | 🟢 准备就绪。客户端已实装 Slice 7(驾驶舱 + 工作台)+ Slice 8/9/10(执行反馈 / 交付结果 / 任务详情抽屉)的 mock 骨架,字段全部对齐 §6 契约 | 见下节 7.7.3 | + +### 7.7.3 cc-haha 当前状态(2026-05-08) + +#### 已实装的 UI 切片(产品包 wireframe 11-product-prototype-wireframes.md) + +| Slice | wireframe | 状态 | 数据通道 | +|---|---|---|---| +| 5 — 资源 UI 下线 | 产品包 08 §1-7 | ✅ 已下线 | — | +| 6 — 极简登录 + 高危审批弹窗 | §1 / §9 | ✅ | 登录走真接口;审批走 `window.__heicodeMockApproval()` mock | +| 7 — 任务驾驶舱首页 + 工作台 | §2 / §3 | ✅ | 内存 seed 任务 | +| **8 — 执行反馈面板** | **§8** | ✅ **新增** | seed(5 个 sub_step + 3 个 sk_tool_call + 3 个 event + 4 个 artifact) | +| **9 — 交付结果面板** | **§10** | ✅ **新增** | seed(4 个 deliverable + 4 个 quality + 3 个 next_action) | +| **10 — 任务详情抽屉** | §audit | ✅ **新增** | seed(用量 / 资源访问 / 审批 / 安全 4 tab) | + +**右侧面板由 task.status 驱动切换**: +- `running` / `awaiting_approval` → ExecutionFeedbackPanel (Slice 8) +- `completed` → DeliveryResultPanel (Slice 9) +- 其他 → TaskCardPanel +- 顶栏「查看详情」→ TaskDetailDrawer (Slice 10) + +#### Slice 8/9/10 字段形态(已和 §6 / §5 对齐) + +```ts +// cc-haha/desktop/src/stores/heicodeTaskStore.ts (已扩展) +type ExecutionState = { + sub_steps: SubStep[] // 状态 ∈ {done|running|waiting|failed|skipped} + sk_tool_calls: SkToolCall[] + events: ExecutionEvent[] + artifacts: Artifact[] // kind ∈ {doc|api|diff|report} + spend_today?: string +} +type DeliveryResult = { + summary: string + deliverables: Deliverable[] // kind ∈ {product-spec|code-diff|test-env|prod-env} + quality: QualityCheck[] + next_actions: Array<{ label: string; intent: string }> +} +type TaskAudit = { + usage: UsageRecord[] // 模型用量 + resources: ResourceAccess[] + approvals: ApprovalRecord[] + security: SecurityRecord[] +} +``` + +完整定义见 [`cc-haha/desktop/src/stores/heicodeTaskStore.ts`](https://gitee.ath.cx:3000/chenchen/heicode-win/src/branch/main/cc-haha/desktop/src/stores/heicodeTaskStore.ts)。 + +### 7.7.4 反向请求 mcp-server 团队 + +1. 🟡 **Slice 8/9 的执行反馈 / 交付结果数据从哪里来?** 当前 §6 task 对象里没有 `execution` / `delivery` 字段。预期是: + - **运行时数据**(sub_steps / sk_tool_calls / artifacts)从 §5 Agnet stub 的 `events` + `metrics` + `deployments/{id}` 派生?还是 §6 task 对象将来扩展成 `task.execution = {...}`? + - **交付结果**(deliverables / quality)类似——§5 events 里捞,还是 task 字段直接带? + + 建议:**§6 task 扩展为这样**(前端只需要订阅一个对象): + + ```jsonc + { + "id": "...", "status": "running", + "thread": [...], "card": {...}, + "execution": { // 新增(task.status ∈ running/awaiting_approval 时填) + "sub_steps": [...], + "sk_tool_calls": [...], + "events": [...], // 可截取最近 N 条 + "artifacts": [...], + "spend_today": "¥12.30" + }, + "delivery": { // 新增(task.status === completed 时填) + "summary": "...", + "deliverables": [...], + "quality": [...], + "next_actions": [...] + }, + "audit": { // 新增(详情抽屉数据,可懒加载) + "usage": [...], "resources": [...], + "approvals": [...], "security": [...] + } + } + ``` + + 或者另起 5 个端点:`GET /api/user/tasks/{id}/execution` / `delivery` / `audit/usage` / `audit/resources` / `audit/approvals`。**两种方案我们都能接,请 mcp-server 团队拍板**。 + +2. 🟢 **高危审批 SSE 通道**(§7.5 旧条目,仍未答复)。建议 (A) SSE:`GET /api/user/approvals/stream`,事件字段对齐 [`cc-haha/desktop/src/stores/approvalStore.ts:ApprovalRequest`](https://gitee.ath.cx:3000/chenchen/heicode-win/src/branch/main/cc-haha/desktop/src/stores/approvalStore.ts)。 + +3. 🟢 **任务状态推送通道**:当任务从 `running` → `completed` / `awaiting_approval` 时,前端如何感知? + - 建议用第 2 项的 SSE 通道复用,或加 `GET /api/user/tasks/stream` SSE + - 短期可用轮询(`GET /api/user/tasks` 5-15s)兜底 + +### 7.7.5 关键 commit(2026-05-08 增量) + +- `cc-haha` 仓库(chenchen/heicode-win main): + - Slice 8/9/10 + 第 3 个种子任务(completed 演示):本次 push(commit hash 待补) + - 新文件:`cc-haha/desktop/src/components/tasks/TaskRightPanels.tsx` + - 改动:`heicodeTaskStore.ts` / `pages/HeicodeTasksHome.tsx` / i18n locales +- 客户端 MSI 安装包(带新 logo + Slice 8/9/10 + Path A 真登录)已重打:62.85 MB · 2026-05-08 14:36 + +--- + +## 7.8 mcp-server 团队对 §7.7 的答复(2026-05-08) + +> 回复人:mcp-server 后端团队 +> 状态:本节是答复,**不是规范** + +### 7.8.1 接受 §7.7.1 两个细节决策 + +| 细节 | Heicode 选 | mcp-server 落地 | +|---|---|---| +| ① `New-Api-User` 头映射 | (b) UUID 当外部 id | **mcp-server 调 NewAPI 时,用 `principal.user_id`(UUID 字符串)作为 `New-Api-User` 头**。等 Heicode 后端给 NewAPI 加 `users.external_id` 字段 + 反查逻辑后,自动生效。当前 P4 透传层用的"email→admin search"方案保留作为 fallback,等 NewAPI 加 `external_id` 后切换 | +| ② `billing_context.provider` | (a) User 表加字段 | **mcp-server 加 `users.billing_provider` 字段**(migration 027),默认 `litellm`。Heicode 后端做 `from-agnet` 同步时通过 mcp-server 接口设为 `newapi` | + +**mcp-server 这边工作量**: +- migration 027 加字段:~1 小时 +- heicode_client.py 切换 `New-Api-User` 用 UUID:~1 小时 +- 让 Heicode 后端能写 `billing_provider`:再加一个 `PUT /api/auth/internal/billing-provider` 内部端点,需要服务令牌(不是用户 JWT):~2 小时 + +合计 **~半天**。 + +### 7.8.2 §7.7.4 第 1 项答复 — Slice 8/9/10 数据 schema + +**选拆端点方案**(即 §7.7.4 的"另起 5 个端点"),不扩展 §6 task 主对象。 + +理由: +- task 主对象保持轻量 + 高频更新(thread 追加 / status 变化) +- execution / delivery / audit **生命周期不同**(execution 实时刷新、delivery 一次性生成、audit 累积),混进 task 会让 GET 响应肿大 +- 拆开后前端可按 panel 切换懒加载,省带宽 +- 后端聚合各自的源(execution 来自 Agnet stub events、audit 来自 audit_logs)也更清晰 + +**计划新增 3 个端点**(合并 audit 5 类为 1 个,前端 tab 切换即可): + +| 端点 | 字段 | 数据源 | 触发更新 | +|---|---|---|---| +| `GET /api/user/tasks/{id}/execution` | `sub_steps[]` / `sk_tool_calls[]` / `events[]` / `artifacts[]` / `spend_today` | Agnet stub events + metrics 派生 | task.status ∈ {running, awaiting_approval} | +| `GET /api/user/tasks/{id}/delivery` | `summary` / `deliverables[]` / `quality[]` / `next_actions[]` | 任务编排器在 status=completed 时生成 | task.status == completed | +| `GET /api/user/tasks/{id}/audit?tab=usage\|resources\|approvals\|security` | 4 tab 各自数据 | audit_logs + 资源使用 + 审批 + 安全事件聚合 | 懒加载 | + +**MVP 阶段**:3 个端点都返回 mock 数据(与 cc-haha seeded 数据格式一致),等真正的 deployment / approval 链路联通后切真。 + +工作量预估:~1.5-2 天(3 端点 + mock 数据 + 测试)。 + +### 7.8.3 §7.7.4 第 2-3 项答复 — 高危审批 SSE + 任务状态推送 + +**接受方案 A(SSE)**,且**合并成单一 SSE 通道**: + +``` +GET /api/user/events/stream +Accept: text/event-stream +Authorization: Bearer +``` + +事件类型(按 SSE `event:` 字段路由): + +| event 类型 | 用途 | 字段对齐 | +|---|---|---| +| `approval.requested` | 高危审批弹窗(cc-haha ApprovalDialog) | `cc-haha/desktop/src/stores/approvalStore.ts:ApprovalRequest` | +| `approval.resolved` | 审批已被响应(其他设备已处理)| 同上 + `decision` 字段 | +| `task.status_changed` | 任务 running → completed/awaiting_approval/failed | `{task_id, old_status, new_status, at}` | +| `task.execution_progress` | sub_step 状态变化 | `{task_id, sub_step_id, status, at}` | +| `heartbeat` | 保活(每 25s)| `{}` | + +合并成单通道的好处: +- 客户端只维护 1 个长连接(节省资源) +- 后端只管 1 个推送泵 +- 不同事件类型互不冲突(按 SSE event name 区分) + +**额外配套端点**: +- `GET /api/user/approvals` — 拉取当前未处理审批列表(启动时拉一次,之后靠 SSE 增量) +- `POST /api/user/approvals/{id}/decision` — 用户选择批准/拒绝/转人工 + +工作量预估:~2 天(SSE 实现 + Approval 模型 + 联调)。 + +### 7.8.4 落地排期(mcp-server 自己点单的) + +按工作量从小到大、不阻塞 Heicode 排序: + +| 顺序 | 任务 | 工作量 | 何时开 | +|---|---|---|---| +| 1 | §7.8.1 细节①② 落地(migration 027 + heicode_client 切换 + 内部 set provider 端点)| 半天 | **现在就能开** | +| 2 | §7.8.3 SSE 审批 + 状态推送(含 Approval 模型 + 4 个端点)| ~2 天 | 1 完成后 | +| 3 | §7.8.2 Slice 8/9/10 三个扩展端点(mock 数据)| ~1.5-2 天 | 2 完成后 | +| 4 | 等 B-1 token:rollout 配 token,P4 真跑 | 30 分钟 | 拿到 token | + +**4 项做完后(约 1 周)mcp-server 端 Heicode 全主流程接口齐齐**。届时 cc-haha + heicode web + agent-manager 真接口落地后,mcp-server 改少量配置即可全联通。 + +### 7.8.5 上游回声 — 给 cc-haha 的小提醒 + +- 任务编排 §6 的 `option.id` 是稳定的(`mvp` / `polish` / `prod` / `modern_web` / `py_backend` / `let_heicode`),可以放心 hardcode 在 i18n 文案里 +- §6 `card.manager_actions[].deeplink` 当前 `/manager/resources?from=task` / `/manager/team?from=task`,等 heicode web (`code.xinghanlab.com`) 接 P1 后,前端实际跳转目标可能换域名 — 建议做成 deeplink scheme 让 mcp-server 后续可调 +- §5 Agnet stub 的 `Idempotency-Key` header 已实现,cc-haha 创建部署时建议传 UUID 避免重复 + +### 7.8.6 关键 commit(mcp-server 增量,2026-05-08) + +| commit / image tag | 内容 | +|---|---| +| `mcp-server:heicode-p4-proxy-20260507` | P4 NewAPI 透传 4 端点 | +| `mcp-server:heicode-p5-stub-v2-20260508` | P5 Agnet stub 12 端点(含 max_tokens 误杀 fix) | +| `mcp-server:heicode-tasks-20260508` | 任务编排 5 端点(**当前生产**) | +| 待发:`mcp-server:heicode-7.8-impl-20260509` | §7.8 落地 | + +镜像数字 digest 等部署后回填。 + +--- + +## 7.9 Heicode 团队对 §7.8 方案的确认(2026-05-08) + +> 回复人:Heicode 客户端 / Manager 侧 +> 范围:mcp-server §7.8 自报方案(拆端点 + 单 SSE 通道 + 内部 set provider 端点)的 ack +> 状态:本节是确认,**不是规范** + +### 7.9.1 方案全部 ack,不阻塞 + +| §7.8 节 | 方案 | Heicode 这边的态度 | +|---|---|---| +| **7.8.1** 细节①② 落地(migration 027 + UUID `New-Api-User` + 内部 set-provider 端点)| 同意。无前端改动 | ✅ 直接开工 | +| **7.8.2** 拆 3 个端点(execution / delivery / audit),不扩 task 主对象 | 同意。理由完全 OK(生命周期不同 + 懒加载 + 数据源清晰)| ✅ cc-haha 已按拆端点字段形态 mock,对得上(见 §7.7.3 字段表)| +| **7.8.3** 单 SSE 通道 `GET /api/user/events/stream` + 5 类 event | 同意。建议这 5 个事件类型的 schema **冻结后写进 [Heicode-接口契约文档.md](./Heicode-接口契约文档.md) §7**,cc-haha 实装订阅时按文档字段对齐 | ✅ | +| **7.8.4** 1→2→3 串行 ~1 周 | 同意。cc-haha 这边 mock 已就绪,等 mcp-server 任意一段端点上线即可切真,不存在阻塞 | ✅ | + +### 7.9.2 §7.8.5 三条提醒已落地(cc-haha 这边) + +| 提醒 | 状态 | 落地点 | +|---|---|---| +| ① `option.id` 稳定,可放心 hardcode | ✅ 已在 cc-haha seed 数据里使用稳定 id(`mvp` / `polish` / `prod` / `modern_web` / `py_backend` / `let_heicode`)。i18n 文案 hardcode 重构推迟到从 mock 切真时再批量做 | `cc-haha/desktop/src/stores/heicodeTaskStore.ts:seedFollowups()` | +| ② `manager_actions[].deeplink` 域名可配置化 | ✅ 新增 `lib/managerLink.ts` 集中处理路径→URL 解析;任务卡 manager_actions 按钮、"去 Manager 准备"主按钮、Slice 9 deliverable primary/secondary 按钮均统一走 `openManagerLink(deeplink)`。后续 Manager 域名变更只需改 `MANAGER_BASE_URL` 一行 | `cc-haha/desktop/src/lib/managerLink.ts` 新建;`HeicodeTasksHome.tsx` + `components/tasks/TaskRightPanels.tsx` 调用 | +| ③ §5 Agnet stub `Idempotency-Key` 建议传 UUID | 🟡 已记录。当前 cc-haha 还未调 §5(任务驾驶舱仍是 mock 态),**Slice 11 mock→真切换时**会在 deployment 创建路径加 `Idempotency-Key: ` 头 | 待办,写进 cc-haha 切真任务清单 | + +### 7.9.3 给 mcp-server 团队的 1 个小请求 + +冻结 §7.8.3 SSE 5 类 event 的字段 schema 后,请同步到 [`Heicode-接口契约文档.md`](./Heicode-接口契约文档.md) 新增章节(建议 §7「Server-Sent Events」),尤其是: + +- `approval.requested` — 字段对齐 `cc-haha/desktop/src/stores/approvalStore.ts:ApprovalRequest`(task_name / operation / target_resource / requesting_role / risk_level / impact_summary / heicode_suggestion / derives_short_lived_credential / ttl_minutes / enqueued_at) +- `approval.resolved` — 加 `decision: "approve" | "reject" | "expired"` + `resolved_at` +- `task.status_changed` — `{task_id, old_status, new_status, status_caption?, at}` +- `task.execution_progress` — `{task_id, sub_step_id, status, caption?, at}` +- `heartbeat` — 空 payload,建议带服务端时间 `{server_time}` 方便客户端时钟漂移检测 + +### 7.9.4 全主流程联调时间线(双方对齐版) + +``` +Day 0 (今天): B-1 token 已交付 ✅ + cc-haha Slice 8/9/10 mock 已完成 ✅ + mcp-server 4 个待开工事项排好 +Day 0.5: mcp-server §7.8.1 完成(细节①② 落地) + B-1 rollout 配 token → P4 真跑可验证 +Day 2.5: mcp-server §7.8.3 完成(SSE 通道 + Approval 模型) +Day 4.5: mcp-server §7.8.2 完成(execution / delivery / audit 3 端点) +Day 5: cc-haha 启动 Slice 11(mock → 真接口切换),1-2 天 +Day 6-7: 全主流程端到端联调(登录 → 输入想法 → 追问 → 任务卡 → 去 Manager → 部署 Agnet → 执行反馈 → 审批 → 交付) +``` + +期间 agent-manager 团队(B-4)与 heicode web 团队(B-3)独立排期,不阻塞 mcp-server ↔ cc-haha 的联调。 + +--- + +## 7.10 Heicode 客户端 Slice 11(mock → §6 真接口)已上线(2026-05-08) + +> 回复人:Heicode 客户端侧 +> 范围:cc-haha 任务驾驶舱 / 工作台从 seed mock 切换到 mcp-server §6 任务编排真接口 + +### 7.10.1 实装内容 + +| 层 | 文件 | 改动 | +|---|---|---| +| **桌面 Bun server 代理** | `cc-haha/src/server/api/heicode-tasks.ts`(新建)| 5 个本地路由 `/api/heicode-tasks/{intent,list,:id,:id/answer,:id/messages}`,从当前 active provider 的 `mcpAuth.accessToken` 取 Bearer 头转发到 `/api/user/tasks/*`(默认 `apimtaiji.azure-api.net/api/mcp`)| +| **路由挂载** | `cc-haha/src/server/router.ts` | 加 `case 'heicode-tasks'` 分发 | +| **前端 typed client** | `cc-haha/desktop/src/lib/heicodeTasksApi.ts`(新建)| 5 个方法 `submitIntent` / `list` / `get` / `answer` / `appendMessage`,包成 `HeicodeTask` 强类型返回 | +| **Store 切真** | `cc-haha/desktop/src/stores/heicodeTaskStore.ts` | 新增 `loadTasks()` action(启动 hydrate)+ `mode: 'mock' \| 'live' \| 'loading' \| 'error'` 状态字段;`submitIntent` / `answerFollowup` / `appendMessage` 全部异步化,乐观更新 + 真接口同步 | +| **UI 状态指示** | `cc-haha/desktop/src/pages/HeicodeTasksHome.tsx` | 启动时 `useEffect(loadTasks)`;首页右下角加 `ModePill`,显示 `live · §6` / `mock` / `加载中` / `mock · 离线` | + +### 7.10.2 行为契约 + +- **登录后启动**:首页 mount 自动 GET `/api/heicode-tasks/list?limit=50`;返回 ≥1 条用真数据替换 seed,否则保留 seed +- **乐观更新**:`submitIntent` / `answer` / `appendMessage` 即时改本地 state,再 await 真接口,**API 成功后用服务端响应覆盖**(拿到真 id + 真 followups + 真 card) +- **失败兜底**:API 报错(401 / 网络断 / 5xx)不清空本地 state,仅切 `mode = 'error'` + 把错误文案写进 `errorMessage`,UI 通过 `ModePill` 的 hover title 暴露详情 +- **mock 任务保护**:本地优化插入的临时 task(id 以 `task-` 开头且 mode 非 live)不会触发 answer/messages 真接口,避免对不存在的 task id 反复 404 + +### 7.10.3 字段对齐验证 + +| §6 契约字段 | cc-haha `HeicodeTask` | 状态 | +|---|---|---| +| `id` / `name` / `status` / `status_caption` | 一致 | ✅ | +| `thread[]` (kind/text/at/followups) | 一致 | ✅ | +| `card.{goal,scope,generated_artifacts,manager_actions[]}` | 一致 | ✅ | +| `created_at` / `updated_at` | 一致 | ✅ | +| `user_id`(§6 返回顶层)| cc-haha 类型未声明,**忽略 ok**(前端不用)| 🟢 | + +### 7.10.4 mcp-server 联调建议 + +cc-haha 客户端这边 Slice 11 已合并到 `chenchen/heicode-win` main 分支。重新出 MSI 后即可端到端验证。建议 mcp-server 团队配合: + +1. 给个 staging / prod 区分的 base URL,让 cc-haha 切环境只改 provider preset +2. §6 `POST /api/user/tasks/intent` 当前返回 `status: 'configuring'` + 空 card,cc-haha 期望首屏立即看到追问选项(thread[1].followups),请确认 mcp-server 在 intent 提交时**首条 heicode 回复就携带 followups**(与 §6.1 契约示例一致) +3. 如果 §6 任务列表对新用户返回空,cc-haha 默认仍展示 seed mock(id `task-saas-001` / `task-feishu-002` / `task-portal-003`)—— 这些是演示数据,不会被认为是用户真实任务;如希望首次登录直接展示空态,告知后我们去掉 seed fallback + +### 7.10.5 客户端**未阻塞**的后续 slice + +| 后续 slice | 等的 mcp-server 端点 | +|---|---| +| Slice 12 — execution 面板切真 | §7.8.2 `GET /api/user/tasks/{id}/execution` 上线 | +| Slice 13 — delivery 面板切真 | §7.8.2 `GET /api/user/tasks/{id}/delivery` | +| Slice 14 — 任务详情抽屉切真 | §7.8.2 `GET /api/user/tasks/{id}/audit` | +| Slice 15 — 高危审批 + 任务状态 SSE 订阅 | §7.8.3 `GET /api/user/events/stream` | + +每个 slice 都是 **拷贝 Slice 11 模板**:加一个 proxy 路由 + 一个 typed client method + 在对应 React 组件里 fetch(懒加载)+ 把 ModePill 切到 live。每个估 ~1-2 小时。等 mcp-server 7.8.x 镜像出来当天即可联调。 + +--- + +## 7.11 Heicode 对 mcp-server 「§7.8.x 下一步顺序?」的答复(2026-05-08) + +> 回复人:Heicode 客户端 / Manager 侧 +> 触发:mcp-server P4 真跑通后问 "继续 §7.9.4 串行做 §7.8.1,还是先把 SSE 5 类 event schema 写进契约文档(响应 §7.9.3 请求)?" + +### 7.11.1 推荐顺序:先 §7.9.3(doc-only),再 §7.8.1 → .3 → .2 + +理由: +- **§7.9.3 是纯文档活**(把 5 个 event 字段 schema 写进 [`Heicode-接口契约文档.md`](./Heicode-接口契约文档.md) §7「Server-Sent Events」),估 30 分钟,**不消耗 mcp-server 工程时间** +- 文档落地后,cc-haha 这边可以**立即**搭 Slice 15 的 SSE 订阅骨架(按合同字段建 typed client + EventSource wrapper),等 §7.8.3 真接口上线当天即可联通 +- §7.8.1(migration 027 + UUID `New-Api-User` + 内部 set-provider 端点)属于**基础设施**(其他 slice 不强依赖它,但它一旦换上后所有代用 email-search 的 fallback 路径就退役)—— 紧排在 §7.9.3 后做即可,**~半天** +- §7.8.3 SSE 通道(~2 天)+ §7.8.2 拆 3 端点(~2 天)按 §7.9.4 既定顺序串行 + +调整后的时间线: + +| Day | mcp-server 端 | Heicode 客户端端(并行)| +|---|---|---| +| **0**(今天)| §7.9.3 doc 落地(30 min)| 等 doc → 起 Slice 15 SSE 订阅骨架(~1h)| +| **0.5** | §7.8.1 落地(migration + heicode_client 切 UUID + 内部端点)| 无影响(透明)| +| **1** | (continued) | Slice 12-14 骨架批量起(拷 Slice 11 模板,3 个端点 ~3h)| +| **2.5** | §7.8.3 SSE 通道完成 | Slice 15 切真(~30 min)| +| **4.5** | §7.8.2 拆 3 端点完成 | Slice 12/13/14 切真(~30 min × 3)| +| **5+** | rollout / 联调 | 全主流程 mock → live | + +净结果:**1 周内 mcp-server ↔ cc-haha 全主流程接口齐**,agent-manager(B-4)和 heicode web(B-3)独立排期不影响。 + +### 7.11.2 关于 `/api/models` 透传 count=0 的观察 + +mcp-server 调的是 `/api/models`(路由 [`heicode/router/api-router.go:25`](../../工单相关/heicode/heicode/router/api-router.go#L25) → `controller.DashboardListModels`),返回 schema: + +```go +// controller/model.go:248-252 +c.JSON(200, gin.H{ + "success": true, + "data": channelId2Models, // map[channelId]string[] —— 渠道维度,不是 user 维度 +}) +``` + +**关键点**: +- 这是**仪表盘视角**(channel admin 看自己渠道下哪些模型),key 是 channelId(UUID 字符串),value 是模型名数组 +- mcp-server-service(user 26)`group=default`,没有任何 channel 关联,所以 `channelId2Models` 自然为空 / 不含它能用的模型 → mcp-server 看到 count=0 是正常的 +- 这个端点不适合作 mcp-server P4「列出某个用户能看到的模型」的数据源 + +**正确的端点是这两个之一**(看 mcp-server 的语义需要): + +| 端点 | 路由 | 返回 | 适用场景 | +|---|---|---|---| +| `GET /api/user/self/models` | [`api-router.go:82`](../../工单相关/heicode/heicode/router/api-router.go#L82) `selfRoute` | `string[]`(按当前 token 持有者的 group 过滤)| 想看「这个用户能用哪些模型」(以调用者身份)| +| `GET /api/user/{id}/models` | (admin 路径,需对应权限)| `string[]` | mcp-server 替任意用户查 | + +mcp-server 端 P4 「列模型」如果是按当前调用用户视角,**应改调 `/api/user/self/models`**,配 `New-Api-User: ` 头切身份。如果 admin 想查别的用户的模型清单,调 `/api/user//models`。`/api/models` 不是 P4 该用的端点。 + +不阻塞主路径,按你当前 fallback 处理即可。等 §7.8.1 落地后顺手切到 `/api/user/{id}/models`(用 `users.external_id` 反查 heicode local id)。 + +### 7.11.3 给 cc-haha 自己的待办(已记入) + +- ✅ Slice 11 (mock → §6 真接口) 完成(见 §7.10) +- 🟡 Slice 15 SSE 订阅骨架 —— 等 §7.9.3 contract doc 出来再起 +- 🟡 Slice 12 / 13 / 14(execution / delivery / audit)骨架 —— 现在就可以起,字段已对齐 §7.8.2 mcp-server 自报方案 + +--- + +## 7.12 Heicode 客户端 Slice 12/13/14 骨架已就绪(2026-05-08) + +> 回复人:Heicode 客户端侧 +> 范围:拷 Slice 11 模板预先搭好 execution / delivery / audit 三个面板的真接口管线,等 mcp-server §7.8.2 上线当天即可零改动联通 + +### 7.12.1 实装内容 + +| 层 | 改动 | +|---|---| +| **代理路由** | `cc-haha/src/server/api/heicode-tasks.ts` 加 3 个 route:`GET /:id/execution` / `GET /:id/delivery` / `GET /:id/audit?tab=...`,全部转发到 `/api/user/tasks/{id}/{path}` | +| **typed client** | `cc-haha/desktop/src/lib/heicodeTasksApi.ts` 加 3 个方法 `execution(id)` / `delivery(id)` / `audit(id, tab?)`,返回 `ExecutionState` / `DeliveryResult` / `TaskAudit` | +| **Store actions** | `cc-haha/desktop/src/stores/heicodeTaskStore.ts` 加 3 个 lazy-fetch action:`loadExecution(taskId)` / `loadDelivery(taskId)` / `loadAudit(taskId, tab?)` —— 每个把对应字段 patch 进 `task.execution` / `.delivery` / `.audit`,复用 mode/errorMessage 失败兜底 | +| **Lazy-load 触发** | `cc-haha/desktop/src/components/tasks/TaskRightPanels.tsx`:`ExecutionFeedbackPanel` / `DeliveryResultPanel` 在 `useEffect([task.id])` 上拉真数据;`TaskDetailDrawer` 在 `useEffect([open, task.id, tab])` 拉真数据(tab 切换重新拉,按 server 过滤减传输量)| + +### 7.12.2 触发 gate(防止对 mock 数据无脑发请求) + +`shouldFetchPanel(state, taskId)` 同时要求: +1. `state.mode === 'live'`(store 已经至少一次成功调通 §6 端点) +2. `taskId` **不**以 `task-` 开头(seed mock id 形如 `task-saas-001`,真接口返回的是 UUID) + +任一不满足则跳过 fetch,保留本地 mock 数据原样展示。这意味着: +- 用户登录前 / 离线时点开 demo 任务,看到的是 seed mock,UI 不发任何请求 +- 登录后真任务的面板首次渲染才发请求 + +### 7.12.3 字段对齐(与 mcp-server §7.8.2 自报方案 1:1) + +| 端点 | 返回字段 | cc-haha 类型 | +|---|---|---| +| `GET /api/user/tasks/{id}/execution` | `sub_steps[]` / `sk_tool_calls[]` / `events[]` / `artifacts[]` / `spend_today` | `ExecutionState`([`heicodeTaskStore.ts`](https://gitee.ath.cx:3000/chenchen/heicode-win/src/branch/main/cc-haha/desktop/src/stores/heicodeTaskStore.ts))| +| `GET /api/user/tasks/{id}/delivery` | `summary` / `deliverables[]` / `quality[]` / `next_actions[]` | `DeliveryResult` | +| `GET /api/user/tasks/{id}/audit?tab=usage\|resources\|approvals\|security` | 4 tab 各自数据 | `TaskAudit` | + +### 7.12.4 联调时切真路径(mcp-server §7.8.2 上线当天) + +零代码改动,仅需 mcp-server 端 3 个端点对外可达 + 字段形态如 §7.12.3。 + +实测路径: +1. cc-haha 客户端登录后进入"小团队任务管理 SaaS"等真任务(id 为 UUID) +2. 右侧执行反馈面板 mount → 自动 GET `/api/heicode-tasks//execution` → 期望返回 `ExecutionState` +3. 顶栏「查看详情」打开 → 自动 GET `/api/heicode-tasks//audit?tab=usage` → 期望返回 `TaskAudit.usage` +4. 切到 resources/approvals/security tab → 重新发对应 query + +如响应字段名 / 形态有偏差,按 §9 排障流程提交,附 `X-Request-Id`。 + +### 7.12.5 校验 + +- ✅ `bunx tsc -b --noEmit`(desktop)干净 +- ✅ `bun build src/server/api/heicode-tasks.ts`(cc-haha 根)通过 (86 modules / 0.55 MB) + +### 7.12.6 还差的最后一片:Slice 15 SSE 订阅 + +等 mcp-server **§7.9.3 SSE 5 类 event schema** 写进 [`Heicode-接口契约文档.md`](./Heicode-接口契约文档.md) 后即可起。约束已锁定到现有 `approvalStore.ts:ApprovalRequest` 字段,30 分钟内可上线骨架。 + +--- + +## 7.13 Heicode 对 mcp-server「两件事」请求的执行回执(2026-05-08,~18:30 本机时间) + +> 回复人:Heicode 客户端 / Manager 侧(chenchen 方) + +### 7.13.1 ✅ 新 NewAPI service token 已就位 + +旧 token 在 7.7.2.1 第二轮 rotate 时失效,mcp-server 那边 K8s secret 没同步导致 P4 透传 401。本轮重新 rotate: + +| 步 | 状态 | +|---|---| +| `users.id=26` access_token SQL UPDATE 写新 32 字符 hex | ✅ | +| 新值通过 `Docs/heicode-svc-token.txt`(gitignored 验证 .gitignore:167)staged | ✅ | +| VM `/tmp/heicode-svc-token.txt` 临时副本删除 | ✅ | + +mcp-server 团队从 `Docs/heicode-svc-token.txt` 读 → `kubectl create secret generic newapi-token --from-literal=HEICODE_NEWAPI_SERVICE_TOKEN= --dry-run=client -o yaml | kubectl apply -f -` → `kubectl rollout restart deployment/mcp-server`,30 秒内 P4 透传 200 恢复。 + +读完后请:① 你方删除 `Docs/heicode-svc-token.txt`;② 我方收到你 ack 后从 DB 再 rotate 一次(与 §7.7.2.1 既有流程一致)。 + +### 7.13.2 ✅ Internal token 已注入 Heicode 后端 + Go 代码已上线 + +mcp-server 在 `Docs/heicode-internal-token.txt` 留的 32 字符 hex(前缀 `bd05cf16…`,gitignored 验证 .gitignore:168)已被消费。落地: + +| 改动 | 位置 | +|---|---| +| 新增 `markBillingProviderNewapi(email)` helper | [`heicode/controller/heicode_agnet_session.go`](https://gitee.ath.cx:3000/chenchen/heicode-win/src/branch/main/heicode/controller/heicode_agnet_session.go)(commit `fc2c811`)| +| 调用挂在 `syncLocalUserFromAgnet` 末尾(goroutine fire-and-forget)| 同上 | +| 容器环境变量 `MCP_SERVER_INTERNAL_TOKEN` | VM `/home/heicode/heicode/heicode/.env` | +| 镜像重建 + 容器重启 | `docker compose -f docker-compose.azure-vm.yml -f docker-compose.override.yml up -d --build heicode` | +| 健康检查 | `Up About a minute (healthy)` + `/api/status` HTTP 200 | +| 镜像清理 | `docker image prune -f` 已跑(0 dangling)| + +**调用约定**: +- 每次 Heicode Manager `/api/user/session/from-agnet` JIT 同步用户(首次或重复登录)后触发 +- HTTP method `PUT`,URL `https://apimtaiji.azure-api.net/api/mcp/api/auth/internal/billing-provider`,header `Authorization: Bearer `,body `{"email": "...", "billing_provider": "newapi"}` +- goroutine 调用,**任何错误都仅 SysLog 不阻塞登录** +- mcp-server 那边内部端点应**幂等**——同一 email 重复 set 不出错 + +mcp-server 团队可以独立测:拿任意 email(比如 `55@55.com`)按上述 PUT 直接打到自己的内部端点验证。 + +本地 `Docs/heicode-internal-token.txt` 副本已删除(值已固化在 VM env)。 + +### 7.13.3 状态 + +- 你方 P4 透传可恢复(拿新 NewAPI token 后) +- 你方 §7.8.1 内部 set-provider 端点已有真实下游调用方(Heicode 后端),可以做端到端联调 +- 你方继续 §7.8.3 SSE 通道 / §7.8.2 拆 3 端点不阻塞 + +加油 🚀 + +--- + +## 7.14 mcp-server §7.8.3 + §7.8.2 全部上线(2026-05-08) + +> 回复人:mcp-server 后端团队 + +### 7.14.1 ✅ §7.8.3 SSE 单通道 + Approval 模型上线 + +| 项 | 状态 | +|---|---| +| migration 028 `heicode_approvals` 表(含 `created_at`/`updated_at`) | ✅ 已应用 | +| `models.HeicodeApproval` ORM | ✅ | +| `app/event_bus.py` 进程内 pub/sub(per-user `asyncio.Queue`,maxsize=256) | ✅ | +| `GET /api/user/events/stream` SSE(25s heartbeat) | ✅ | +| `GET /api/user/approvals` 待处理列表 + lazy expire | ✅ | +| `POST /api/user/approvals/{id}/decision` (approve / reject) | ✅ | +| `POST /api/auth/internal/approvals` (service-token 鉴权,创建 + 广播 approval.requested) | ✅ | +| 修复 `_is_expired()` offset-naive vs aware 比较 bug | ✅ | +| 联调 smoke:12/12 全过 | ✅ 见 §7.14.3 | + +**镜像**:`taiji.azurecr.io/mcp-server:heicode-7.8.3-sse-v2-20260508` + +### 7.14.2 ✅ §7.8.2 三个任务扩展端点上线 + +按 §7.8.2 自报方案,**不扩 task 主对象**,而是开 3 个独立端点(懒加载 / 数据源各异): + +| 端点 | 数据形态 | 数据源 | 生命周期 | +|---|---|---|---| +| `GET /api/user/tasks/{id}/execution` | `ExecutionState`(sub_steps/sk_tool_calls/events/artifacts/spend_today)| 当前 deterministic mock,5 sub_step 按 task.status 自动切 done/running/waiting | task.status ∈ {running, awaiting_approval, completed} 才填 | +| `GET /api/user/tasks/{id}/delivery` | `DeliveryResult`(summary/deliverables/quality/next_actions)| 当前 deterministic mock,4 deliverable + 4 quality + 3 next_action | 仅 task.status == completed 时填 | +| `GET /api/user/tasks/{id}/audit?tab=usage\|resources\|approvals\|security` | `TaskAudit` 4 tab | **approvals 真表查 heicode_approvals**;其他 3 tab deterministic mock | 懒加载,传 tab 只填该 tab | + +**字段形态严格对齐 cc-haha `heicodeTaskStore.ts:ExecutionState/DeliveryResult/TaskAudit`**——cc-haha 现有 mock 数据可零改动切真。 + +错误码: +- 400 `INVALID_TAB`(audit)/ `TASK_STATE_INVALID` +- 404 `NOT_FOUND` / 403 `FORBIDDEN_SCOPE` / 401 + +**镜像**:`taiji.azurecr.io/mcp-server:heicode-7.8.2-tasks-ext-20260508` + +**联调 smoke**:13/13 全过(含 keys 完整性 / sub_step 状态分布 / 跨用户 403 / 完成态 deliverables=4 / 非完成态 deliverables=0 / tab 过滤 / INVALID_TAB / NOT_FOUND / 401) + +### 7.14.3 SSE smoke 详情 + +``` +✓ internal POST /approvals +✓ SSE 收到 approval.requested +✓ GET /approvals total >= 1 +✓ invalid decision -> 400 +✓ NOT_FOUND -> 404 +✓ internal no-token -> 401 +✓ internal wrong-token -> 403 +✓ decide approve +✓ SSE 收到 approval.resolved +✓ duplicate decide -> 409 +✓ SSE heartbeat +✓ /api/auth/me 回归 +``` + +### 7.14.4 接口文档同步 + +`Docs/Heicode-接口契约文档.md` 已更新到 v2.2: +- §7(SSE)写在 v2.1 增量 +- §6.6 / §6.7 / §6.8(execution / delivery / audit)写在 v2.2 增量 +- 接口总数 13 → 34 → 38 → **41** + +### 7.14.5 反向请求 — Heicode 团队侧的下一步 + +请 cc-haha: +1. **Slice 12(execution 切真)**:`heicodeTasksApi.ts` 加 `execution(id)` → 调 `GET /api/user/tasks/{id}/execution`,store action `loadExecution(taskId)` 把响应 patch 进 `task.execution`。预计 ~30 min(按 §7.12.2 模板)。 +2. **Slice 13(delivery 切真)**:同上,`delivery(id)` + `loadDelivery(taskId)`。 +3. **Slice 14(audit 抽屉切真)**:`audit(id, tab?)` + `loadAudit(taskId, tab?)`,按 tab 懒加载。 +4. **Slice 11+ SSE 客户端**:可参考契约文档 §7.9 实现,订阅 `approval.requested` / `approval.resolved` / `task.status_changed` / `task.execution_progress`。 + +### 7.14.6 mcp-server 自己的 next(按 §7.11.1 时间线) + +- ⏳ Day 4.5+ — 等 cc-haha Slice 12/13/14 联调,按反馈优化 mock 数据 +- ⏳ 后续可把 `/execution` 的数据源接到 Agnet stub events / metrics 派生(无前端改动) +- ⏳ 后续可把 `/audit` 的 usage 接到 NewAPI logs(需要 NewAPI service token 落地) + + +--- + +## 7.15 Heicode 对 §7.14 的回执(2026-05-08,~21:00) + +> 回复人:Heicode 客户端 / Manager 侧(chenchen 方) + +### 7.15.1 ✅ §7.14.5 第 1-3 条(Slice 12/13/14 切真)**早已完成**,请刷新理解 + +mcp-server 团队列出的"请 cc-haha 做"清单 1-3 项实际**已经在 commit `15ac001` (`feat(client): slices 8-14 — exec/delivery/audit panels + §6 task orchestration wiring`,今天下午)落地**,详情见 §7.12(`Heicode 客户端 Slice 12/13/14 骨架已就绪`)。当前仓库 `chenchen/heicode-win/main` 的状态: + +| §7.14.5 项 | 文件 / 函数 | 状态 | +|---|---|---| +| 1. Slice 12 execution 切真 | `cc-haha/desktop/src/lib/heicodeTasksApi.ts` `execution(id)` + `cc-haha/desktop/src/stores/heicodeTaskStore.ts` `loadExecution(taskId)` + `TaskRightPanels.tsx:ExecutionFeedbackPanel` `useEffect → loadExecution` | ✅ 已合 | +| 2. Slice 13 delivery 切真 | 同上 `delivery(id)` / `loadDelivery(taskId)` / `DeliveryResultPanel useEffect` | ✅ 已合 | +| 3. Slice 14 audit 抽屉切真 | `audit(id, tab?)` / `loadAudit(taskId, tab?)` / `TaskDetailDrawer useEffect([open, task.id, tab])` 按 tab 重新拉 | ✅ 已合 | + +**触发 gate**:`shouldFetchPanel` 函数同时要求 `mode === 'live'`(store 至少调通过一次 §6)+ `taskId` 不以 `task-` 开头(避 mock seed)。所以 mcp-server §7.8.2 三端点上线后,**只要 cc-haha 在登录态下打开 UUID 任务,三块 panel 就自动从真接口拉数据**——零代码改动,零部署。 + +**等待联调实测**:用户装新 MSI(已部署 `c09e6b6c…` → 现升级为 `903ec8a6…` 含一键登录) + 一键登录 + 通过 `POST /api/user/tasks/intent` 创个真任务 + 进任务 → 看 ModePill 是否变 `live · §6`、看右侧 panel 数据是否来自真接口。如有偏差按 §9 排障流程提交 X-Request-Id。 + +### 7.15.2 🟡 §7.14.5 第 4 条(Slice 15 SSE)**正在起骨架** + +按 §7.14.1 上线的 SSE 单通道(`GET /api/user/events/stream`,4 个 event 类型 + heartbeat)我方现在起客户端订阅骨架: + +| 模块 | 计划 | +|---|---| +| `cc-haha/src/server/api/heicode-tasks.ts`(已有) | 加一条 SSE 转发路由 `GET /api/heicode-tasks/events/stream`,把 `Authorization: Bearer ` 注入后透传 mcp-server 的 SSE,**保持长连接**(`fetch` 流式 → Bun `Response` ReadableStream)| +| `cc-haha/desktop/src/lib/heicodeEventsClient.ts`(新建)| EventSource wrapper:自动重连、heartbeat 监听、按 event name 路由到 callback | +| `cc-haha/desktop/src/stores/approvalStore.ts`(已存在)| 监听 `approval.requested` → `enqueue`;`approval.resolved` → 更新对应 request 状态 | +| `cc-haha/desktop/src/stores/heicodeTaskStore.ts`(已存在)| 监听 `task.status_changed` → patch 对应 task 的 status;`task.execution_progress` → patch sub_step 状态 | +| `cc-haha/desktop/src/main.tsx` | 启动 hooks:登录后启 SSE 订阅,登出后断开 | + +预计 ~2 小时上线。完成后客户端的高危审批弹窗 + 任务驾驶舱状态都会**实时**收 mcp-server 推送,不再依赖轮询。 + +### 7.15.3 关于「要不要 commit 这 2 份 md」 + +**强烈建议你方 commit + push**: +- 这 2 份 md 是 mcp-server / agent-manager / Heicode 三方对齐的 SoT 看板,不进 git 等于私聊 +- 不含敏感凭证(token 走 `Docs/heicode-svc-token.txt` 已 .gitignore:167 排除;internal token 走 `.gitignore:168`) +- 让契约演进 v2.0 → v2.2 + 进度 §7.0 → §7.15 在 git 历史里可追溯 +- agent-manager 团队后续接手时直接拉就有完整上下文 + +我这边没有 `taijibaga/taiji-AI-PAD.git` 的 push 权限,请你方直接: + +```bash +cd /path/to/taiji-AI-PAD +git add Docs/Heicode-对接进度与待办.md Docs/Heicode-接口契约文档.md Docs/Heicode-完整调用流程图.md +git commit -m "docs(heicode): v2.2 contract + progress §7.0 → §7.15" +git push +``` + +### 7.15.4 NewAPI service token rollout(B-1 round 3) + +token 已 staged 在 `Docs/heicode-svc-token.txt`(gitignored),32 字符 hex。等你方 `kubectl create secret ... | apply` 覆盖 + `rollout restart`,P4 透传立刻恢复 200。完成后跟我们 ack,我会 rotate 一次关闭通道(按 §7.7.2.1 协议)。 + +--- diff --git a/Docs/Heicode-接口契约文档.md b/Docs/Heicode-接口契约文档.md new file mode 100644 index 0000000..abb47cd --- /dev/null +++ b/Docs/Heicode-接口契约文档.md @@ -0,0 +1,1577 @@ +# Heicode ↔ mcp-server 接口契约文档 + +**版本**: v2.2 +**生效日期**: 2026-05-08 +**状态**: 已上线生产,可直接对接 +**当前镜像**: `taiji.azurecr.io/mcp-server:heicode-7.8.2-tasks-ext-20260508` + +> 本文档列出 Heicode 客户端可调用的全部 mcp-server 接口、入参与出参。 +> 所有接口已在生产环境通过端到端测试。 + +**v2.2 修订**(2026-05-08,本次): +- 新增 §6.6 / §6.7 / §6.8(execution / delivery / audit)— Slice 8/9/10 配套,3 端点全过 13/13 smoke +- **接口总数:38 → 41** + +**v2.1 修订**(2026-05-08): +- 新增 §7 Server-Sent Events(5 类 event + 配套 REST)— §7.8.3 SSE 单通道全过 12/12 smoke +- **接口总数:34 → 38** + +**v2.0 修订**(2026-05-08): +- 新增 §4 NewAPI 元数据透传(4 接口) +- 新增 §5 Agnet 平台本地 stub(12 接口,mock 数据,前端可立即联调) +- 新增 §6 任务编排(5 接口,cc-haha 任务驾驶舱 / 工作台 配套) +- **接口总数:13 → 34** + +> 📍 调用关系全景见 [`Heicode-完整调用流程图.md`](./Heicode-完整调用流程图.md)。 +> 📍 进度与待办见 [`Heicode-对接进度与待办.md`](./Heicode-对接进度与待办.md)。 + +--- + +## 0. 接入信息 + +### Base URL + +``` +https://apimtaiji.azure-api.net/api/mcp +``` + +完整路径示例: + +``` +POST https://apimtaiji.azure-api.net/api/mcp/api/auth/login +GET https://apimtaiji.azure-api.net/api/mcp/api/resources +``` + +### ⚠️ 重要:登录接口被两类调用方共用 + +经过代码核实(2026-05-05),mcp-server 的登录接口实际被以下两个调用方依赖: + +| 调用方 | 调用方式 | 关键依赖字段 | +|---|---|---| +| **heicode 前端** (`web/default/src/features/auth/api.ts`) | 浏览器跨域,`Authorization: Bearer` | `data.token`, `data.refreshToken`, `data.user.{id,name,email,role,channelId}` | +| **heicode 后端** (`controller/heicode_agnet_session.go`) | 服务端调 `/me` `/refresh`,验证前端给的 token | `data.{id,email,name,role,channelId,status}` | + +字段形态**任何变更都会破坏对接**。增加字段安全;删/改字段名/字段类型 = breaking change。 + +### 通用请求头 + +| Header | 必填 | 说明 | +|---|---|---| +| `Content-Type: application/json` | POST/PUT 时是 | 请求体 JSON 格式 | +| `Authorization: Bearer ` | 受保护接口必填 | JWT access token | +| `X-Request-Id: ` | 建议 | 客户端生成,便于排障反查日志 | + +### 通用响应封装 + +**成功**: +```json +{ + "success": true, + "data": { ... } +} +``` + +部分接口含 `message`: +```json +{ + "success": true, + "data": { ... }, + "message": "操作成功" +} +``` + +**失败(FastAPI 默认)**: +```json +{ + "detail": "邮箱或密码错误" +} +``` + +**失败(结构化错误)**: +```json +{ + "detail": { + "code": "RESOURCE_GRANT_INVALID", + "message": "allowed_actions 超出对应 binding.permission_scope: ['repo:admin']" + } +} +``` + +### Token 模型 + +| Token | TTL | 用途 | +|---|---|---| +| Access Token | 24 小时 | 调业务接口(含 /me、/logout) | +| Refresh Token | 7 天 | 仅用于 `/refresh` 换新 access | + +JWT claims:`sub`(user_id)、`email`、`role`、`channelId`、`type`(access/refresh)、`iat`、`exp`。 + +### Role 字段值与 heicode 前端解析 + +mcp-server 现有 role 取值(按用户类型):`user`, `channel_admin`, `billing_admin`, `operations_admin`, `admin`, `super_admin`, `provider_admin`。 + +heicode 前端 (`api.ts:259-261`) **硬编码**解析为 3 档: + +| mcp-server role 值 | heicode 前端解析 | 数值 | +|---|---|---| +| `root` | root | 100 | +| `admin` | admin | 10 | +| `user` | user | 1 | +| 其他(channel_admin, super_admin, provider_admin 等) | 默认 user | 1 | + +**对 Heicode 客户端的实际影响**: +- Heicode 客户端预期登录的角色 = `user`,前端解析为 1 ✓ +- 高权限角色(admin/root)由 **heicode 后端独立白名单** (`HEICODE_ROOT_EMAILS` / `HEICODE_ADMIN_EMAILS` 环境变量) 决定,**不信任** mcp-server 返回的 role 字段 +- mcp-server 这边**无需调整** role 取值,保持现有取值即可 + +### CORS 当前状态 + +mcp-server 生产环境 `CORS_ORIGINS` 未显式配置 → fallback 到 `["*"]`。代码同时设置 `allow_credentials=True`。 + +| 调用方式 | 当前是否能用 | +|---|---| +| `Authorization: Bearer ` | ✅ 能用(heicode 前端走这条) | +| 浏览器 cookie + credentials | ❌ 浏览器规范禁止 `*` + credentials 同用 | + +**Heicode 前端用 Bearer,不受影响**。如需后续硬化(去掉 `*`),需 heicode 团队提供前端实际部署域名清单。 + +--- + +## 1. 登录认证(4 接口) + +### 1.1 POST /api/auth/login + +**用途**: 账号密码登录,换取 token 对。 + +**请求** + +```http +POST /api/auth/login +Content-Type: application/json +``` + +```json +{ + "email": "user@example.com", + "password": "YourPassword123", + "role": "user" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `email` | string | 是 | 用户邮箱 | +| `password` | string | 是 | 密码 | +| `role` | string | 是 | Heicode 客户端固定传 `"user"` | + +**成功响应 200** + +```json +{ + "success": true, + "data": { + "token": "eyJhbGc...", + "refreshToken": "eyJhbGc...", + "user": { + "id": "b00a7b8e-9e8b-463d-9593-a3b4d0006778", + "name": "张三", + "email": "user@example.com", + "role": "user", + "channelId": "6e6fc470-76f8-4bb1-8ea4-625dc5b12bc6" + } + } +} +``` + +**错误响应** + +| HTTP | 含义 | +|---|---| +| 401 | `邮箱或密码错误` | +| 403 | `账户已被禁用` / `权限不足` | +| 422 | 请求体 Pydantic 校验失败(邮箱格式不合法等) | +| 429 | 登录尝试过于频繁;响应头含 `Retry-After: 60` | + +**限流**: 每 IP 每 60 秒最多 5 次(不区分成功失败)。 + +--- + +### 1.2 GET /api/auth/me + +**用途**: 校验 access token 有效性,返回当前用户资料。 + +> ⚠️ **高频调用 + 双调用方依赖**: +> - heicode 前端:客户端启动 + token 续期后立即调一次 +> - heicode 后端:每次走 `from-agnet` 流程时调一次(即每次"建立 Manager 会话") +> - 字段形态被 hardcode 解析(id/email/name/role/channelId/status),**绝不能改** +> - 性能敏感:响应时间影响登录用户体验 + +**请求** + +```http +GET /api/auth/me +Authorization: Bearer +``` + +**成功响应 200** + +```json +{ + "success": true, + "data": { + "id": "b00a7b8e-9e8b-463d-9593-a3b4d0006778", + "email": "user@example.com", + "name": "张三", + "role": "user", + "channelId": "6e6fc470-76f8-4bb1-8ea4-625dc5b12bc6", + "status": "active", + "subscriptionTier": "free", + "lastLoginAt": "2026-05-05T06:01:23.456789" + } +} +``` + +**错误响应** + +| HTTP | 含义 | +|---|---| +| 401 | token 无效 / 过期 / 已登出 / 用户不存在 | +| 403 | 账户已被禁用 | + +--- + +### 1.3 POST /api/auth/refresh + +**用途**: 用 refresh token 换取新的 access + refresh token 对。 + +**请求** + +```http +POST /api/auth/refresh +Authorization: Bearer +``` + +> ⚠️ 必须传 **refresh token**,传 access token 会被拒绝。 + +**成功响应 200** + +```json +{ + "success": true, + "data": { + "token": "eyJhbGc...", + "refreshToken": "eyJhbGc..." + } +} +``` + +客户端收到新 token 对后**应替换本地缓存**(旧的也作废)。 + +**错误响应** + +| HTTP | 含义 | +|---|---| +| 401 | refresh token 无效 / 过期 / 错传了 access token(`必须使用 refresh token 调用此接口`) | + +--- + +### 1.4 POST /api/auth/logout + +**用途**: 登出,把当前 access token 加入黑名单。 + +**请求** + +```http +POST /api/auth/logout +Authorization: Bearer +``` + +**成功响应 200** + +```json +{ + "success": true, + "data": null, + "message": "登出成功" +} +``` + +**说明**: 容错性强,token 黑名单写入失败也会返回 200。客户端总是清理本地 token + 跳登录页。 + +> ⚠️ **登出双调用约定**:heicode 前端登出时会**同时**调用: +> 1. `GET /api/user/logout`(heicode 后端,清 heicode session cookie) +> 2. `POST /api/auth/logout`(mcp-server,本接口;token 加黑名单) +> +> 两次调用都做容错处理(不阻断登出体验)。前端最后总是清 localStorage 跳登录页。 + +--- + +## 2. 资源绑定 ResourceBinding(5 接口) + +### 2.0 公共说明 + +**对象语义**: 用户绑定到 Heicode 的外部资源(Git 仓库 / SK / 项目文档 / 云账号 / 云资源)。 + +**安全红线**: +- DB **只保存元数据** + `secret_ref`,**不保存明文密钥** +- `metadata` / `constraints` / `permission_scope` 中**不得**出现 key 包含以下关键词的字段(不区分大小写): + ``` + password, token, secret, private_key, access_key, credential + ``` + 违反返回 **422 `RESOURCE_GRANT_SECRET_REJECTED`** +- 例外:key 名为 `secret_ref` 视为引用(如 `vault://...`),允许通过 + +**枚举值**: + +| 字段 | 允许值 | +|---|---| +| `type` | `git` / `sk` / `project_doc` / `cloud_account` / `cloud_resource` | +| `status` | `pending` / `active` / `disabled` / `revoked` | + +--- + +### 2.1 POST /api/resources + +**用途**: 创建资源绑定。 + +**请求** + +```http +POST /api/resources +Authorization: Bearer +Content-Type: application/json +``` + +```json +{ + "type": "git", + "name": "heicode-main-repo", + "external_ref": "https://example.com/org/heicode.git", + "metadata": { + "provider": "gitee", + "default_branch": "main" + }, + "permission_scope": ["repo:read", "repo:write:current-branch"], + "constraints": { + "ref": "main", + "allowed_paths": "services/**,docs/**" + }, + "secret_ref": "vault://secret/users/{user_id}/bindings/heicode-main-repo", + "status": "active" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `type` | string (enum) | 是 | 资源类型,见 §2.0 | +| `name` | string (≤255) | 是 | 用户可见名称 | +| `external_ref` | string | 否 | 外部资源定位(repo URL、subscription ID 等) | +| `metadata` | object | 否,默认 `{}` | 元数据;不得含敏感 key | +| `permission_scope` | string[] | 否,默认 `[]` | 该资源允许的最大动作集合 | +| `constraints` | object | 否,默认 `{}` | 路径/分支/网络等限制 | +| `secret_ref` | string (≤500) | 否 | 凭证引用(`vault://...`),不是密钥本身 | +| `status` | string (enum) | 否,默认 `pending` | 见 §2.0 | + +**成功响应 200** + +```json +{ + "success": true, + "data": { + "id": "88963d56-5a95-4863-8a0b-91a83e456028", + "user_id": "b00a7b8e-9e8b-463d-9593-a3b4d0006778", + "type": "git", + "name": "heicode-main-repo", + "external_ref": "https://example.com/org/heicode.git", + "metadata": { "provider": "gitee", "default_branch": "main" }, + "permission_scope": ["repo:read", "repo:write:current-branch"], + "constraints": { "ref": "main", "allowed_paths": "services/**,docs/**" }, + "secret_ref": "vault://secret/users/.../bindings/heicode-main-repo", + "status": "active", + "created_by": "b00a7b8e-9e8b-463d-9593-a3b4d0006778", + "updated_by": "b00a7b8e-9e8b-463d-9593-a3b4d0006778", + "created_at": "2026-05-05T06:10:00.123456", + "updated_at": "2026-05-05T06:10:00.123456" + } +} +``` + +**错误响应** + +| HTTP | code | 含义 | +|---|---|---| +| 401 | — | 未登录 | +| 422 | — | type/status 枚举不合法 | +| 422 | `RESOURCE_GRANT_SECRET_REJECTED` | metadata/constraints/permission_scope 含敏感字段 | + +--- + +### 2.2 GET /api/resources + +**用途**: 列出当前登录用户的资源绑定。 + +**请求** + +```http +GET /api/resources?type=git&status=active&limit=100&offset=0 +Authorization: Bearer +``` + +| Query | 类型 | 默认 | 说明 | +|---|---|---|---| +| `type` | string | — | 按类型过滤 | +| `status` | string | — | 按状态过滤 | +| `limit` | int (1-500) | 100 | 返回上限 | +| `offset` | int (≥0) | 0 | 偏移 | + +**成功响应 200** + +```json +{ + "success": true, + "data": { + "items": [ + { "id": "...", "type": "git", "name": "...", ... } + ], + "total": 1, + "offset": 0, + "limit": 100 + } +} +``` + +> `items` 元素结构同 §2.1 的成功响应 `data` 字段。 + +--- + +### 2.3 GET /api/resources/{binding_id} + +**用途**: 获取单个资源绑定详情。 + +**请求** + +```http +GET /api/resources/88963d56-5a95-4863-8a0b-91a83e456028 +Authorization: Bearer +``` + +**成功响应 200**: 同 §2.1 的 `data` 字段。 + +**错误响应** + +| HTTP | 含义 | +|---|---| +| 400 | binding_id 不是有效 UUID | +| 401 | 未登录 | +| 403 | 该资源不属于当前用户 | +| 404 | 资源不存在 | + +--- + +### 2.4 PUT /api/resources/{binding_id} + +**用途**: 更新资源绑定(部分字段)。 + +**请求** + +```http +PUT /api/resources/88963d56-5a95-4863-8a0b-91a83e456028 +Authorization: Bearer +Content-Type: application/json +``` + +```json +{ + "name": "heicode-main-repo-renamed", + "permission_scope": ["repo:read"], + "status": "active" +} +``` + +可更新字段(全部可选):`name` / `external_ref` / `metadata` / `permission_scope` / `constraints` / `secret_ref` / `status`。 + +未传的字段保持原值。 + +**成功响应 200**: 同 §2.1(含更新后字段)。 + +**错误响应**: 同 §2.3 + §2.1 的 422。 + +--- + +### 2.5 DELETE /api/resources/{binding_id} + +**用途**: 软删除资源绑定(设 `status=revoked`,保留历史)。 + +**请求** + +```http +DELETE /api/resources/88963d56-5a95-4863-8a0b-91a83e456028 +Authorization: Bearer +``` + +**成功响应 200** + +```json +{ + "success": true, + "data": { + "id": "88963d56-5a95-4863-8a0b-91a83e456028", + "status": "revoked" + } +} +``` + +**说明**: 软删除后 `GET /api/resources/{id}` 仍可读到(status=revoked)。基于该 binding 已存在的 grant 不会自动撤销 — 业务层自行判断或调 `DELETE /api/resource-grants/{id}`。 + +--- + +## 3. 资源授权 ResourceGrant(4 接口) + +### 3.0 公共说明 + +**对象语义**: 把某个 ResourceBinding 授给某个角色 / 子 Agent 使用。 + +**核心约束**: +- `allowed_actions` 必须是对应 binding 的 `permission_scope` 的**子集** + - 违反 → **400 `RESOURCE_GRANT_INVALID`** +- `constraints` 不得放宽对应 binding 的 `constraints`(应用层校验) +- 仅能基于**自己的** binding 创建 grant +- 同一类敏感字段拒绝规则适用于 `constraints` / `allowed_actions` + +**枚举值**: + +| 字段 | 允许值 | +|---|---| +| `status` | `active` / `suspended` / `revoked` / `expired` | +| `role`(建议值) | `product` / `frontend` / `backend` / `reviewer` / `ops` / 自定义字符串 | + +--- + +### 3.1 POST /api/resource-grants + +**用途**: 创建资源授权。 + +**请求** + +```http +POST /api/resource-grants +Authorization: Bearer +Content-Type: application/json +``` + +```json +{ + "resource_id": "88963d56-5a95-4863-8a0b-91a83e456028", + "binding_scope": "main", + "role": "backend", + "agent_id": null, + "allowed_actions": ["repo:read"], + "constraints": { + "ref": "main", + "allowed_paths": "services/api/**" + }, + "expires_at": "2026-06-05T00:00:00", + "status": "active" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `resource_id` | string (UUID) | 是 | 对应 ResourceBinding ID | +| `binding_scope` | string (≤255) | 是 | repo/ref/path 或云资源引用 | +| `role` | string | 否 | 子 Agent 角色 | +| `agent_id` | string (UUID) | 否 | 子 Agent ID;空表示授下次该角色部署 | +| `allowed_actions` | string[] | 否 | 必须是 binding.permission_scope 的子集 | +| `constraints` | object | 否 | 限制条件 | +| `expires_at` | string (ISO 8601) | 否 | 过期时间;高危资源建议必填 | +| `status` | string (enum) | 否,默认 `active` | 见 §3.0 | + +**成功响应 200** + +```json +{ + "success": true, + "data": { + "id": "1e8d7a4d-3a2c-4d5e-9b1f-12c3d4e5f6a7", + "user_id": "b00a7b8e-9e8b-463d-9593-a3b4d0006778", + "binding_scope": "main", + "resource_id": "88963d56-5a95-4863-8a0b-91a83e456028", + "role": "backend", + "agent_id": null, + "allowed_actions": ["repo:read"], + "constraints": { "ref": "main", "allowed_paths": "services/api/**" }, + "status": "active", + "expires_at": "2026-06-05T00:00:00", + "created_by": "b00a7b8e-9e8b-463d-9593-a3b4d0006778", + "revoked_by": null, + "created_at": "2026-05-05T06:15:00.123456", + "revoked_at": null + } +} +``` + +**错误响应** + +| HTTP | code | 含义 | +|---|---|---| +| 400 | — | resource_id / agent_id 非合法 UUID | +| 400 | `RESOURCE_GRANT_INVALID` | binding 状态不允许新建授权(如 disabled/revoked),或 `allowed_actions` 超出 binding.permission_scope | +| 401 | — | 未登录 | +| 403 | `FORBIDDEN_SCOPE` | 不能基于他人的 binding 创建授权 | +| 404 | `NOT_FOUND` | 对应 binding 不存在 | +| 422 | — | status 枚举不合法 | +| 422 | `RESOURCE_GRANT_SECRET_REJECTED` | constraints/allowed_actions 含敏感字段 | + +--- + +### 3.2 GET /api/resource-grants + +**用途**: 列出当前用户的资源授权。 + +**请求** + +```http +GET /api/resource-grants?resource_id=...&role=backend&binding_scope=main&status=active&limit=100&offset=0 +Authorization: Bearer +``` + +| Query | 类型 | 默认 | 说明 | +|---|---|---|---| +| `resource_id` | string (UUID) | — | 按 binding 过滤 | +| `role` | string | — | 按角色过滤 | +| `binding_scope` | string | — | 按作用域过滤 | +| `status` | string | — | 按状态过滤 | +| `limit` | int (1-500) | 100 | 返回上限 | +| `offset` | int (≥0) | 0 | 偏移 | + +**成功响应 200** + +```json +{ + "success": true, + "data": { + "items": [ { "id": "...", ... } ], + "total": 1, + "offset": 0, + "limit": 100 + } +} +``` + +--- + +### 3.3 GET /api/resource-grants/{grant_id} + +**用途**: 获取单个授权详情。 + +**请求** + +```http +GET /api/resource-grants/1e8d7a4d-3a2c-4d5e-9b1f-12c3d4e5f6a7 +Authorization: Bearer +``` + +**成功响应 200**: 同 §3.1 的 `data` 字段。 + +**错误响应**: + +| HTTP | code | 含义 | +|---|---|---| +| 400 | — | grant_id 非合法 UUID | +| 401 | — | 未登录 | +| 403 | `FORBIDDEN_SCOPE` | 不属于当前用户 | +| 404 | `NOT_FOUND` | 授权不存在 | + +--- + +### 3.4 DELETE /api/resource-grants/{grant_id} + +**用途**: 撤销授权(软删除:`status=revoked` + `revoked_at` + `revoked_by`)。**幂等**:重复撤销返回 200。 + +**请求** + +```http +DELETE /api/resource-grants/1e8d7a4d-3a2c-4d5e-9b1f-12c3d4e5f6a7 +Authorization: Bearer +``` + +**成功响应 200**: 同 §3.1 的 `data` 字段(含 revoked_at / revoked_by)。 + +**错误响应**: 同 §3.3。 + +--- + +## 4. 完整使用流程示例 + +### 4.1 客户端启动 + +``` +1. 读取本地缓存的 access token +2. GET /api/auth/me + - 200 → 进入主界面,刷新本地用户资料 + - 401 → 看是否有 refresh token + ├─ 有 → POST /api/auth/refresh + │ ├─ 200 → 替换 token 对,重试 /me + │ └─ 401 → 清理本地 token,跳登录页 + └─ 无 → 跳登录页 +``` + +### 4.2 用户登录 + +``` +POST /api/auth/login {email, password, role: "user"} + ├─ 200 → 存 token 对 + user 资料,进主界面 + ├─ 401 → 显示"邮箱或密码错误" + ├─ 429 → 显示"尝试过于频繁,请 N 秒后再试"(N = Retry-After) + └─ 其他 → 通用错误提示 +``` + +### 4.3 绑定 Git 资源 + +``` +POST /api/resources +{ + "type": "git", + "name": "my-repo", + "external_ref": "https://example.com/org/my-repo.git", + "permission_scope": ["repo:read", "repo:write:current-branch"], + "constraints": { "ref": "main" }, + "secret_ref": "vault://secret/users/{user_id}/bindings/my-repo", + "status": "active" +} + → 200, data.id = +``` + +### 4.4 把绑定授给一个 backend 子 Agent 角色 + +``` +POST /api/resource-grants +{ + "resource_id": "", + "binding_scope": "main", + "role": "backend", + "allowed_actions": ["repo:read"] +} + → 200, data.id = +``` + +### 4.5 撤销与清理 + +``` +DELETE /api/resource-grants/{grant_id} # 撤销授权 +DELETE /api/resources/{binding_id} # 软删除 binding +``` + +### 4.6 业务请求中 token 过期 + +``` +任意业务接口返回 401 + └→ POST /api/auth/refresh (用 refresh token) + ├─ 200 → 替换 token,重试原请求 + └─ 401 → 清理 token,跳登录页 +``` + +### 4.7 用户登出 + +``` +POST /api/auth/logout + → 不论结果都清理本地 token + 跳登录页 +``` + +--- + +## 5. 错误码索引 + +| code | HTTP | 接口 | 含义 | +|---|---|---|---| +| `RESOURCE_GRANT_INVALID` | 400 | resource-grants | binding 状态非法 / `allowed_actions` 超出 binding.permission_scope | +| `RESOURCE_GRANT_SECRET_REJECTED` | 422 | resources / resource-grants | metadata/constraints/permission_scope/allowed_actions 含敏感字段 | +| `FORBIDDEN_SCOPE` | 403 | resources / resource-grants | 跨用户访问别人的资源/授权 | +| `NOT_FOUND` | 404 | resource-grants | 引用的 binding 或 grant 不存在 | +| —(detail 字符串) | 401 | 全部 | 未登录 / token 失效 / 已登出 | +| —(detail 字符串) | 403 | 登录 / /me | 账户已禁用 / 权限不足 | +| —(detail 字符串) | 429 | login | 限流(每 IP 5 次/60s) | +| —(detail 数组) | 422 | 全部 | Pydantic 校验失败(邮箱格式、枚举值等) | + +--- + +## 6. 字段速查 + +### 资源类型枚举 + +| `type` | 子 Agent 可见内容 | `permission_scope` 示例 | +|---|---|---| +| `git` | repo URL、ref、允许路径、读写范围 | `repo:read`, `repo:write:current-branch` | +| `sk` | SK 来源、版本、允许/禁止策略 | `sk:read`, `sk:list` | +| `project_doc` | 文档引用、版本、可读范围 | `doc:read`, `doc:list` | +| `cloud_account` | account/project/subscription 元数据、允许动作 | `cloud:list`, `cloud:read` | +| `cloud_resource` | 资源 ID、环境、网络边界、允许动作 | `vm:ssh:approved-window`, `service:restart` | + +### 状态机 + +**ResourceBinding**: +``` +pending ─→ active ─┬→ disabled ─→ active ... + └→ revoked (终态) +``` + +**ResourceGrant**: +``` +active ─┬→ suspended ─→ active ... + ├→ revoked (终态,由 DELETE 设置) + └→ expired (终态,由 expires_at 触发,当前由调用方判断) +``` + +--- + +## 7. 安全与配额 + +| 项 | 数值/规则 | +|---|---| +| 登录限流 | 每 IP 每 60 秒 ≤ 5 次(不区分成功失败) | +| Access token TTL | 24 小时 | +| Refresh token TTL | 7 天 | +| API 列表分页上限 | `limit ≤ 500` | +| `name` 长度上限 | 255 字符 | +| `secret_ref` 长度上限 | 500 字符 | +| 敏感字段拒绝模式 | key 含 `password\|token\|secret\|private_key\|access_key\|credential`(不区分大小写)→ 422 | +| 例外 key | `secret_ref` 不算敏感(视为引用) | + +--- + +## 8. 测试账号(仅联调) + +| 角色 | 邮箱 | 密码 | +|---|---|---| +| 普通用户 | `55@55.com` | `By@123456.` | + +> Heicode 客户端固定使用 `role="user"`。其他角色(channel/admin)由 mcp-server 现有业务使用,不开放给 Heicode。 + +--- + +## 9. 排障 + +如发现接口行为与本文档不一致: + +1. 客户端**应在所有请求带 `X-Request-Id` 头**(UUID 格式) +2. 报告内容: + - 完整请求 URL + Method + Headers + Body + - 完整响应 HTTP 状态 + Body + - `X-Request-Id` 值 + - 时间戳(精确到秒) +3. 后端按 `X-Request-Id` 反查日志和审计 + +--- + +## 4. NewAPI 元数据透传(4 接口) + +### 4.0 公共说明 + +Heicode 决策 ④(透传方案):mcp-server 包一层 `/api/user/heicode/*`,背后调 NewAPI(`code.xinghanlab.com`)admin token,按当前登录用户的 email 反查 NewAPI 本地 user_id 后透传。 + +**前端只对接 mcp-server 一个域**,不直连 `code.xinghanlab.com`,零 CORS 问题。 + +**当前状态**:代码已上线 + 4 端点全注册;**未配置 admin token 时返回 503 `HEICODE_NEWAPI_NOT_CONFIGURED`**。Heicode 配齐 token 后立即生效,**无需重新构建/部署**。 + +### 4.1 GET /api/user/heicode/balance + +**响应 200**: +```json +{ + "success": true, + "data": { + "heicodeUserId": 123, + "email": "user@example.com", + "username": "user", + "displayName": "张三", + "group": "default", + "status": 1, + "quota": 100000, + "usedQuota": 12345, + "requestCount": 78 + } +} +``` + +### 4.2 GET /api/user/heicode/models + +**响应 200**: +```json +{ + "success": true, + "data": { + "heicodeUserId": 123, + "email": "...", + "items": [/* NewAPI 全部模型清单(admin 视角)*/], + "count": 28 + } +} +``` + +> 客户端结合 `/balance` 的 `group` 字段做用户可见性过滤。 + +### 4.3 GET /api/user/heicode/usage + +Query: `days` (1-90,默认 30) + +**响应 200**: +```json +{ + "success": true, + "data": { + "heicodeUserId": 123, + "email": "...", + "days": 30, + "items": [/* NewAPI 按日 quota 统计 */], + "count": 30 + } +} +``` + +### 4.4 GET /api/user/heicode/logs + +Query: `limit` (1-200,默认 50),`page` (默认 1) + +**响应 200**: +```json +{ + "success": true, + "data": { + "heicodeUserId": 123, + "email": "...", + "page": 1, + "limit": 50, + "items": [/* NewAPI 调用日志 */], + "count": 50 + } +} +``` + +### 4.x 共通错误码 + +| HTTP | code | 含义 | +|---|---|---| +| 401 | — | 未登录 | +| 404 | `HEICODE_USER_NOT_FOUND` | 在 NewAPI 找不到 email 的用户(用户需先在 Heicode 完成首次登录建账) | +| 502 | `HEICODE_NEWAPI_UPSTREAM_ERROR` | NewAPI 调用失败 | +| 503 | `HEICODE_NEWAPI_NOT_CONFIGURED` | mcp-server 未配置 admin token(见 §B-1) | + +--- + +## 5. Agnet 平台本地 stub(12 接口) + +### 5.0 公共说明 + +按 Heicode 仓库 `docs/integration/agnet-platform-request-contract.md` 字段形态返回 mock 数据。**用于前端联调**,真实 agent-manager 12 接口落地后切换为出站调用,**字段 / 路径 / 错误码 0 改动**。 + +存储:内存 dict,重启清空(仅联调用)。 + +**通用 Header**: +| Header | 必填 | 说明 | +|---|---|---| +| `Authorization: Bearer ` | 是 | 复用登录 token | +| `X-Correlation-Id` | 建议 | mcp-server 生成;全链路追踪 ID | +| `X-Request-Id` | 建议 | 单次 HTTP 请求追踪 | +| `Idempotency-Key` | 创建类建议 | 同 key 返回原响应(避免重复创建) | + +**通用错误码**: + +| HTTP | business code | +|---|---| +| 400 | `POLICY_REJECTED` (字段缺/非法 / 高危无审批) | +| 403 | `MODEL_NOT_ALLOWED` / `FORBIDDEN_SCOPE` / `POLICY_REJECTED` (高危) | +| 404 | `NOT_FOUND` | +| 409 | `DEPLOYMENT_CONFLICT` | +| 422 | `RESOURCE_GRANT_INVALID` / `RESOURCE_GRANT_SECRET_REJECTED` / `RESOURCE_GRANT_SECRET_REF_REQUIRED` | + +### 5.1 POST /api/agnet/deployments + +**请求体**(详见 heicode 仓库 `agnet-platform-request-contract.md §2`,stub 严格按其字段校验): + +```json +{ + "orchestration_plan": { + "intent_id": "intent_xxx", + "template_hint": "manager-resource-binding", + "objective": "...", + "risk_level": "low", + "budget": {"max_tokens": 100000, "max_cost_usd": 30, "max_duration_sec": 7200}, + "user_context": {"user_id": "...", "channel_id": "..."}, + "billing_context": {"provider": "newapi"}, + "agent_runtime": {"platform": "agnet", "agents": [...]}, + "metadata": {"correlation_id": "corr_xxx"}, + "agents": [{ + "role_template": "builder", + "goal": "...", + "default_model_id": "...", + "resource_grants": [{ + "grant_id": "...", "resource_id": "...", "resource_type": "git", + "user_id": "...", "binding_scope": "main", + "target_role": "builder", "target_agent_ref": "...", + "permission_scope": ["repo:read"], "status": "active", + "secret_ref": "vault://...", + "constraints": {}, "metadata": {} + }] + }] + } +} +``` + +**响应 200**: +```json +{"success": true, "data": { + "deployment_id": "dep_xxx", + "status": "accepted", + "agent_instances": [{"instance_id": "agi_xxx", "role": "builder", "phase": "pending"}] +}} +``` + +**校验规则(stub 已实现)**: +- `risk_level=high` 必须有 `resource_grants[].constraints.approval_id` 或 `audit.approval_id` +- `billing_context.provider` ∈ {`newapi`, `litellm`} +- 凭据型资源(git/sk/cloud_*)必须有 `secret_ref` +- `metadata` / `constraints` / `audit` key 不得含敏感词(password/token/secret/private_key/access_key/credential)→ 422 `RESOURCE_GRANT_SECRET_REJECTED` +- `target_role` 必须等于所属 agent 的 `role_template` + +### 5.2 GET /api/agnet/deployments + +Query: `user_id`, `binding_scope`, `status`, `limit` (1-500) +**响应 200**: `{success, data: {items: [...], total: N}}` + +### 5.3 GET /api/agnet/deployments/{id} + +**响应 200**: 完整 deployment 详情(含 agent_instances / resource_grants_summary / budget_consumed) + +### 5.4 POST /api/agnet/deployments/{id}/stop + +请求体可空。**幂等**:已 stopped 重复调返 200。 + +### 5.5 GET /api/agnet/deployments/{id}/logs + +Query: `agent_instance_id`, `stream` (stdout/stderr/system/audit), `since`, `limit` (1-1000), `cursor` +**响应**: `{items: [{log_id, deployment_id, agent_instance_id, stream, level, message, redacted, occurred_at}], next_cursor, total}` + +### 5.6 GET /api/agnet/deployments/{id}/logs/stream (SSE) + +返回 `text/event-stream`,事件类型:`log` / `heartbeat` / `done`。 + +### 5.7 GET /api/agnet/projects/{binding_scope}/dashboard-snapshot + +> 路径名 `projects` 是契约保留旧名,参数实际是 `binding_scope`。 + +**响应**: `{project_id, binding_scope, active_instances, phase_distribution, failure_rate_1h, avg_task_duration, budget, resource_usage, updated_at}` + +### 5.8 GET /api/agnet/deployments/{id}/metrics + +Query: `window` (默认 15m), `step` (默认 60s) +**响应**: `{deployment_id, window, step, series: [{metric, unit, points: [[time, value], ...]}, ...]}` + +返回 9 个 metric: `tokens_used` / `cost_usd` / `duration_sec` / `cpu_millicores` / `memory_mb` / `restart_count` / `tool_call_count` / `error_count` / `queue_latency_ms` + +### 5.9 GET /api/agnet/deployments/{id}/events + +事件名(mock 已 seed): `deployment.accepted` / `instance.phase_changed` / `sk_snapshot_refreshed` / `resource_grant.attached` / `resource_grant.revoked` / `budget.threshold_reached` / `deployment.failed` / `deployment.stopped` + +**响应**: `{items: [{event_id, event, schema_version, user_id, channel_id, binding_scope, deployment_id, correlation_id, occurred_at}], next_cursor, total}` + +### 5.10 GET /api/agnet/audit-logs + +Query: `user_id`, `binding_scope`, `actor`, `action`, `since`, `limit` (1-1000), `cursor` + +### 5.11 POST /api/agnet/sk-snapshots/resolve + +**请求体**: `{"deployment_id": "dep_xxx"}` +**响应**: `{deployment_id, items: [snapshot], total: 1}` + +snapshot 形态: `{snapshot_id, deployment_id, user_id, binding_scope, source_type, source_ref, artifact_ref, checksum, status, resolved_at}` + +### 5.12 GET /api/agnet/deployments/{id}/sk-snapshots + +Query: `source_type`, `limit`, `cursor` + +--- + +## 6. 任务编排(5 接口) — cc-haha 任务驾驶舱 / 工作台 配套 + +字段形态严格对齐 `cc-haha/desktop/src/stores/heicodeTaskStore.ts`。 + +**对象语义**:用户输入想法(intent)→ Heicode 追问(followups)→ 全部答完 → 生成任务卡(TaskCard)→ 进入"准备资源 / 部署"阶段。 + +**状态机**: +``` +draft → configuring → running → (后续 awaiting_approval / completed / failed / paused) +``` + +### 6.1 POST /api/user/tasks/intent + +**请求体**: +```json +{ "intent": "做一个面向小团队的任务协作 SaaS", "name": "可选" } +``` + +**响应 200** (`HeicodeTask`): +```json +{ + "success": true, + "data": { + "id": "uuid", + "user_id": "uuid", + "name": "做一个面向小团队的任务协作 SaaS", + "status": "configuring", + "status_caption": "等待你回答几个问题", + "intent": "做一个面向小团队的任务协作 SaaS", + "thread": [ + {"kind": "user", "text": "...", "at": 1715168400000}, + {"kind": "heicode", "text": "好的。我先问你两个问题...", "at": 1715168400001, + "followups": [ + {"id": "scope", "question": "你希望第一阶段交付到什么程度?", + "options": [ + {"id": "mvp", "label": "MVP:能跑通主流程"}, + {"id": "polish", "label": "完整功能 + UI 细节打磨"}, + {"id": "prod", "label": "直接上生产", "risk": "high-risk"} + ]}, + {"id": "tech", "question": "技术栈倾向?", + "options": [ + {"id": "modern_web", "label": "现代 Web(React + Node/Python)"}, + {"id": "py_backend", "label": "Python 后端为主"}, + {"id": "let_heicode", "label": "让 Heicode 决定"} + ]} + ]} + ], + "card": null, + "created_at": 1715168400000, + "updated_at": 1715168400000 + } +} +``` + +### 6.2 GET /api/user/tasks + +Query: `status`, `limit` (1-200), `offset` +**响应**: `{items: [HeicodeTask], total, offset, limit}` + +### 6.3 GET /api/user/tasks/{id} + +**响应**: 单个 `HeicodeTask` +**错误**: 404 `NOT_FOUND` / 403 `FORBIDDEN_SCOPE`(跨用户) + +### 6.4 POST /api/user/tasks/{id}/answer + +**请求体**: `{"question_id": "scope", "option_id": "mvp"}` + +**响应 200**:返回更新后的 `HeicodeTask`。 +- 还有未答的 followup → `status` 仍 `configuring`,`card` 仍 null +- 所有 followup 都答完 → `status` → `running`,**`card` 自动生成**: + +```json +{ + "card": { + "goal": "做一个面向小团队的任务协作 SaaS", + "scope": ["MVP 范围:核心功能跑通", "技术债务可后续清理", "暂不做高级 UI/性能优化"], + "generated_artifacts": ["产品说明", "原型描述", "技术方案", "代码骨架(React + Node/Python 后端)"], + "manager_actions": [ + {"label": "去 Manager 准备资源", "deeplink": "/manager/resources?from=task"}, + {"label": "查看团队建议", "deeplink": "/manager/team?from=task"} + ] + } +} +``` + +**错误**: +- 400 `INVALID_OPTION` — option_id 不在该 followup 的 options 列表里 +- 404 `QUESTION_NOT_FOUND` — question_id 不存在 +- 400 `TASK_STATE_INVALID` — task 当前 status 不允许再答 + +### 6.5 POST /api/user/tasks/{id}/messages + +**请求体**: `{"text": "再补充:希望支持飞书集成"}` +**响应 200**:thread 追加 user 消息 + heicode ack("收到。我会把这条要求纳入任务上下文。")。MVP 不再触发新 followups。 + +### 6.6 GET /api/user/tasks/{id}/execution(Slice 8 — 执行反馈) + +**请求**: 仅需 `Authorization: Bearer `,无 query。 +**响应 200**:`ExecutionState`(字段对齐 `cc-haha/desktop/src/stores/heicodeTaskStore.ts:ExecutionState`) + +```jsonc +{ + "success": true, + "data": { + "sub_steps": [ + {"id":"step_1","title":"需求分解","status":"done","caption":"已完成","at": 1778237397000} + // status ∈ done|running|waiting|failed|skipped + ], + "sk_tool_calls": [ + {"id":"tc_1","name":"read_codebase","status":"done","summary":"...","at": 1778237397200} + ], + "events": [ + {"id":"ev_1","level":"info","message":"任务启动...","at": 1778237397100} + // level ∈ info|warn|error + ], + "artifacts": [ + {"id":"art_1","kind":"doc","label":"产品说明(PRD)","url":"/manager/artifacts//prd.md"} + // kind ∈ doc|api|diff|report + ], + "spend_today": "¥12.30" // 可空 + } +} +``` + +**生命周期约束**:仅 `task.status ∈ {running, awaiting_approval, completed}` 才有数据;其他状态返回**空 ExecutionState**(5 个字段都是空数组 / spend_today=null)。前端可据此显示"等待启动"占位。 + +**错误**: 404 `NOT_FOUND` / 403 `FORBIDDEN_SCOPE` / 401。 + +### 6.7 GET /api/user/tasks/{id}/delivery(Slice 9 — 交付结果) + +**响应 200**:`DeliveryResult` + +```jsonc +{ + "success": true, + "data": { + "summary": "任务「...」已交付。包含产品说明、代码骨架、测试报告 3 大类产物...", + "deliverables": [ + { + "id":"deliv_1","kind":"product-spec","title":"产品说明 PRD v1", + "primary_action": {"label":"查看文档","deeplink":"/manager/artifacts//prd.md"}, + "secondary_action": {"label":"...","deeplink":"..."} // 可选 + } + // kind ∈ product-spec|code-diff|test-env|prod-env + ], + "quality": [ + {"id":"q_1","label":"单元测试覆盖率","status":"pass","detail":"82%"} + // status ∈ pass|warn|fail + ], + "next_actions": [ + {"label":"去 Manager 准备资源","intent":"manager.resources"} + ] + } +} +``` + +**生命周期约束**:仅 `task.status == "completed"` 时返回完整数据;其他状态所有字段都为空字符串 / 空数组。前端可据此显示"任务尚未完成"占位。 + +**错误**: 404 / 403 / 401。 + +### 6.8 GET /api/user/tasks/{id}/audit(Slice 10 — 任务详情抽屉) + +**Query**: `tab` 可选,∈ `usage | resources | approvals | security`。**不传**则一次性返回 4 个 tab 全量;**传**则只填该 tab,其他 tab 字段返回空数组(schema 始终稳定)。 + +**响应 200**:`TaskAudit` + +```jsonc +{ + "success": true, + "data": { + "usage": [{"id":"u_1","model":"gpt-4o-mini","input_tokens":12400,"output_tokens":3200,"cost":"¥4.20","at_iso":"2026-05-08T..."}], + "resources": [{"id":"r_1","resource":"git/heicode-frontend","scope":"read","last_used_iso":"2026-05-08T..."}], + "approvals": [{"id":"","operation":"deploy","target_resource":"prod-cluster","risk_level":"high","decision":"approve","enqueued_iso":"...","resolved_iso":"..."}], + "security": [{"id":"s_1","event":"task_created","level":"info","at_iso":"..."}] + } +} +``` + +**`approvals` tab 是真表查询**(heicode_approvals where task_id=...,按 enqueued_at DESC,limit 50),其他 3 tab 当前是 deterministic mock,后续可接入真实数据源(usage 接 NewAPI logs / resources 接 resource_grants 使用日志 / security 接 audit_logs)。 + +**错误**: +- 400 `INVALID_TAB` — tab 不在允许枚举内 +- 404 / 403 / 401 + +--- + +## 7. Server-Sent Events(单通道事件推送) + +> **状态**:契约定义(mcp-server 端实现待发,预计 Day 2.5 上线 — 见 [`Heicode-对接进度与待办.md §7.11.1`](./Heicode-对接进度与待办.md))。本节字段形态**已冻结**,cc-haha 可立即按此搭 Slice 15 SSE 订阅骨架。 + +### 7.0 公共说明 + +mcp-server 提供**唯一一条 SSE 长连接**给前端订阅所有用户级实时事件(高危审批、任务状态、执行进度、保活)。 + +合并成单通道的好处: +- 客户端只维护 1 个 EventSource(节省资源) +- 后端只管 1 个推送泵 +- 不同事件类型按 SSE `event:` 字段路由,互不冲突 + +### 7.1 GET /api/user/events/stream + +**用途**:当前登录用户的实时事件流。 + +**请求**: +```http +GET /api/user/events/stream +Accept: text/event-stream +Authorization: Bearer +``` + +> ⚠️ 浏览器 `EventSource` 不支持自定义 header。前端建议用 `fetch` + `ReadableStream` 手动解析 SSE,或使用支持 header 的 polyfill(如 `eventsource-polyfill`)。 + +**响应**: `200 OK`,`Content-Type: text/event-stream`,长连接。 + +**事件路由**(按 SSE `event:` 字段): + +| event 类型 | 用途 | +|---|---| +| `approval.requested` | 高危审批请求 — 弹审批弹窗 | +| `approval.resolved` | 审批已被响应(其他设备处理 / 过期)— 关闭对应弹窗 | +| `task.status_changed` | 任务 status 变化 — 切右侧面板 | +| `task.execution_progress` | sub_step 状态变化 — 刷新执行反馈面板 | +| `heartbeat` | 保活(每 25s)| + +### 7.2 event: approval.requested + +字段对齐 cc-haha `desktop/src/stores/approvalStore.ts:ApprovalRequest`。 + +**SSE 帧**: +```text +event: approval.requested +data: {"approval_id":"apr_xxx","task_id":"task_xxx","task_name":"…","operation":"在生产环境部署服务","target_resource":"vm://newapi-prod","requesting_role":"operator","risk_level":"high","impact_summary":["重启服务","回滚预案已备好"],"heicode_suggestion":"approve","derives_short_lived_credential":true,"ttl_minutes":60,"enqueued_at":"2026-05-08T10:00:00Z"} + +``` + +**字段**: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `approval_id` | string | 审批唯一 ID | +| `task_id` | string | 关联任务 ID(可空,如系统级审批)| +| `task_name` | string | 任务名(弹窗显示)| +| `operation` | string | 待批准的操作描述 | +| `target_resource` | string | 受影响资源(如 `vm://newapi-prod` / `repo://org/foo`)| +| `requesting_role` | string | 发起请求的子 Agent 角色(builder / operator / reviewer 等)| +| `risk_level` | enum | `low` / `medium` / `high` | +| `impact_summary` | string[] | 影响摘要 bullet 列表 | +| `heicode_suggestion` | enum | Heicode 给出的建议:`approve` / `reject` / `delegate` | +| `derives_short_lived_credential` | bool | 批准后是否会生成短期凭据注入 Pod | +| `ttl_minutes` | int | 审批有效时长(minutes),过期自动 expired | +| `enqueued_at` | string (ISO 8601) | 入队时间 | + +### 7.3 event: approval.resolved + +**SSE 帧**: +```text +event: approval.resolved +data: {"approval_id":"apr_xxx","decision":"approve","resolved_by":"user_55","resolved_at":"2026-05-08T10:01:23Z"} + +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `approval_id` | string | 与 `approval.requested` 对应 | +| `decision` | enum | `approve` / `reject` / `expired` | +| `resolved_by` | string \| null | 谁做的决定(其他设备同步时使用;`expired` 时为 null)| +| `resolved_at` | string (ISO 8601) | 决定时间 | + +客户端收到该事件应**关闭对应 approval_id 的弹窗**(避免多设备登录时弹窗鬼影)。 + +### 7.4 event: task.status_changed + +**SSE 帧**: +```text +event: task.status_changed +data: {"task_id":"task_xxx","old_status":"running","new_status":"completed","status_caption":"已交付","at":"2026-05-08T10:05:00Z"} + +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `task_id` | string | 任务 ID | +| `old_status` | string | 之前的 status(参见 §6.1 task.status 枚举)| +| `new_status` | string | 当前 status | +| `status_caption` | string \| null | 副标题(如"等待生产部署审批")| +| `at` | string (ISO 8601) | 变化时间 | + +客户端应据此切换右侧面板(running/awaiting_approval → ExecutionFeedbackPanel;completed → DeliveryResultPanel)。 + +### 7.5 event: task.execution_progress + +**SSE 帧**: +```text +event: task.execution_progress +data: {"task_id":"task_xxx","sub_step_id":"step_compile","status":"done","caption":"编译通过","at":"2026-05-08T10:03:12Z"} + +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `task_id` | string | 任务 ID | +| `sub_step_id` | string | 步骤 ID(与 `/api/user/tasks/{id}/execution` 返回的 `sub_steps[].id` 对应)| +| `status` | enum | `waiting` / `running` / `done` / `failed` / `skipped` | +| `caption` | string \| null | 步骤当前描述 | +| `at` | string (ISO 8601) | 变化时间 | + +客户端应据此**就地刷新** ExecutionFeedbackPanel 中对应 sub_step 行(不需要重拉整个 execution)。 + +### 7.6 event: heartbeat + +**SSE 帧**: +```text +event: heartbeat +data: {"server_time":"2026-05-08T10:00:25Z"} + +``` + +| 字段 | 类型 | 说明 | +|---|---|---| +| `server_time` | string (ISO 8601) | 服务端时间,便于客户端检测时钟漂移 | + +发送频率:每 **25 秒**一次。客户端 60 秒未收到 heartbeat 应主动重连。 + +### 7.7 配套 REST 端点 + +实时通道之外,前端启动时需要拉一次"当前未处理审批",并提供"用户响应审批"入口: + +#### 7.7.1 GET /api/user/approvals + +**用途**:拉取当前用户**待响应**的审批列表(启动时调一次,后续靠 SSE 增量)。 + +**响应 200**: +```json +{ + "success": true, + "data": { + "items": [/* ApprovalRequest 对象数组,字段同 §7.2 */], + "total": 0 + } +} +``` + +#### 7.7.2 POST /api/user/approvals/{approval_id}/decision + +**用途**:用户在弹窗里选择批准/拒绝。 + +**请求体**: +```json +{ "decision": "approve" } +``` +`decision` ∈ `approve` / `reject`(不能传 `expired`,那是服务端自动设置)。 + +**响应 200**: 返回更新后的 `ApprovalRequest` 对象(含 `decision` / `resolved_by` / `resolved_at`)。 + +mcp-server 此时会**广播** `approval.resolved` 事件到该用户所有 SSE 连接(其他设备的弹窗会自动关闭)。 + +**错误响应**: + +| HTTP | code | 含义 | +|---|---|---| +| 404 | `NOT_FOUND` | approval_id 不存在 | +| 403 | `FORBIDDEN_SCOPE` | 该审批不属于当前用户 | +| 410 | `APPROVAL_EXPIRED` | 已超时,决定无效 | +| 409 | `ALREADY_RESOLVED` | 已被响应(其他设备处理)| + +### 7.8 错误处理(SSE) + +| 场景 | mcp-server 行为 | 客户端建议 | +|---|---|---| +| 401(token 过期) | 立即关闭流 | 调 `/api/auth/refresh` 后重连 | +| 503 / 网络抖动 | — | 指数退避重连(1s / 2s / 5s / 10s)| +| 60s 无 heartbeat | — | 主动重连 | + +### 7.9 客户端订阅最简实现(参考) + +```ts +// cc-haha/desktop/src/lib/heicodeEventStream.ts +async function subscribe(token: string, handlers: { [evt: string]: (data: any) => void }) { + const res = await fetch(`${BASE}/api/user/events/stream`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' }, + }) + const reader = res.body!.getReader() + const decoder = new TextDecoder() + let buf = '' + while (true) { + const { value, done } = await reader.read() + if (done) break + buf += decoder.decode(value, { stream: true }) + let idx + while ((idx = buf.indexOf('\n\n')) !== -1) { + const frame = buf.slice(0, idx); buf = buf.slice(idx + 2) + const evtLine = frame.split('\n').find(l => l.startsWith('event:')) + const dataLine = frame.split('\n').find(l => l.startsWith('data:')) + if (!evtLine || !dataLine) continue + const event = evtLine.slice(6).trim() + const data = JSON.parse(dataLine.slice(5).trim()) + handlers[event]?.(data) + } + } +} +``` + +--- + +## 10. 版本历史 + +| 版本 | 日期 | 变更 | +|---|---|---| +| v1.0 | 2026-05-05 | 初版:登录 4 接口 + P1 资源模型 9 接口 | +| v2.0 | 2026-05-08 | + NewAPI 元数据透传 4 接口(§4) + Agnet stub 12 接口(§5) + 任务编排 5 接口(§6)。**总计 34 接口** | +| v2.1 | 2026-05-08 | + SSE 单通道事件推送契约(§7)+ 配套审批 REST 2 接口(§7.7)。**冻结字段形态供 cc-haha Slice 15 起骨架;mcp-server 实现 Day 2.5 上线** | + +待开发(参考 [Heicode-对接进度与待办.md](./Heicode-对接进度与待办.md)): +- 高危审批 SSE 推送(cc-haha 客户端 ApprovalDialog 配套) +- P2 Secret Broker(等 Vault 部署) +- P5 出站调用真实 Agnet 平台(等 agent-manager 12 接口) + +--- + +## B. Heicode 团队待办清单 + +> 按 mcp-server 当前已交付的能力,Heicode 这边需要做以下 5 件事让全链路真跑起来。括号内是优先级与阻塞影响。 + +### B-1 配置 NewAPI service token(🔴 高优 — 阻塞 P4 真跑) + +**Heicode 端要做**: +1. 在 NewAPI 创建一个专用账号 `mcp-server-service`,角色 = admin +2. 生成永久 access token +3. 把 token + admin user_id 给 mcp-server 团队(**通过安全通道,绝不写 Git/聊天/邮件**) + +**mcp-server 端要做**(拿到后 ~30 分钟): +- 通过 K8s secret 注入 env:`HEICODE_NEWAPI_SERVICE_TOKEN` + `HEICODE_NEWAPI_ADMIN_USER_ID` +- `kubectl rollout restart` 即可生效(**无需重新构建镜像**) + +### B-2 答复 P4 2 个细节(🟡 中优 — 不阻塞但影响实现细节) + +详见 [`Heicode-对接进度与待办.md §2.3.2`](./Heicode-对接进度与待办.md)。两个问题: +- 细节①:mcp-server 调 NewAPI 时 `New-Api-User` 怎么映射到 heicode 本地 user_id?(mcp-server 当前用 admin search by email,工作但有 1 次往返开销) +- 细节②:子 Agent 部署 `billing_context.provider` 怎么决定?(mcp-server 默认 `litellm`,需 Heicode 用户场景默认 `newapi` 时给规则) + +### B-3 协调 heicode web 团队对接 P1 资源绑定 UI(🟡 中优) + +cc-haha 桌面客户端切片 5 已下线 P1 UI(按 Heicode 产品包 08-client-guide.md 边界)。资源绑定 UI 现在归 **heicode web (`code.xinghanlab.com`)**。需要: +- heicode web 团队接 mcp-server 的 9 个 P1 接口(§2 / §3) +- 否则用户从客户端任务卡点"去 Manager 准备"会 404 + +### B-4 推动 agent-manager 团队实现 12 真实接口(🟢 中优 — 阻塞 P5 出站联调) + +agent-manager 12 接口契约见: +- Heicode 仓库 `docs/integration/agnet-platform-request-contract.md` +- mcp-server 仓库 `Docs/Agent-Manager-Heicode对接需求文档.md` + +mcp-server 已上线 stub(§5)字段形态完全一致,agent-manager 实装时可直接对照。 + +**Heicode 这边要给 mcp-server**: +- agent-manager 团队对接负责人 +- staging 联调环境地址(建议 `staging-agnet.xinghanlab.com`) +- 服务令牌方案确认(建议 A pre-shared bearer 起步) + +### B-5 联调测试(🟢 低优 — 可并行) + +cc-haha 客户端 / heicode web 拿到本文档后即可开始联调: +- 登录链路(§1)已实测,无需 mcp-server 配合 +- P1 资源(§2 / §3)已实测,无需 mcp-server 配合 +- Agnet stub(§5)已实测,**前端可立即开始任务执行联调** +- 任务编排(§6)已实测,**cc-haha 可立即把任务驾驶舱 / 工作台从 mock 切真** +- NewAPI 透传(§4)等 B-1 完成后真跑 + +如发现接口问题,按 §9 排障流程提交。 + +--- + +## C. 总账速查 + +| 模块 | 章节 | 端点数 | 状态 | +|---|---|---|---| +| 登录认证 | §1 | 4 | ✅ 上线 + Heicode 已对接 | +| P1 资源绑定 | §2 | 5 | ✅ 上线 | +| P1 资源授权 | §3 | 4 | ✅ 上线 | +| P4 NewAPI 透传 | §4 | 4 | ✅ 上线(待 B-1 token 真跑)| +| P5 Agnet stub | §5 | 12 | ✅ 上线(mock 数据) | +| 任务编排 | §6 | 5 | ✅ 上线 | +| **合计** | — | **34** | **全部上线** | diff --git a/services/mcp-server/app/auth.py b/services/mcp-server/app/auth.py index 7a7b8a5..7518339 100644 --- a/services/mcp-server/app/auth.py +++ b/services/mcp-server/app/auth.py @@ -205,6 +205,8 @@ async def require_auth( "/api/admin/auth/login", "/api/providers/auth/login", "/api/auth/login", # 添加统一登录接口 + "/api/auth/internal/billing-provider", # Heicode §7.8.1 内部端点(用 HEICODE_INTERNAL_SERVICE_TOKEN 鉴权,不走 user JWT) + "/api/auth/internal/approvals", # Heicode §7.8.3 内部端点(同上) "/agents/templates", # 模板列表公开访问 } # 允许公开路径和非 API/agents 路径 diff --git a/services/mcp-server/app/event_bus.py b/services/mcp-server/app/event_bus.py new file mode 100644 index 0000000..04fd038 --- /dev/null +++ b/services/mcp-server/app/event_bus.py @@ -0,0 +1,71 @@ +""" +Heicode SSE 事件总线(§7.8.3 配套) + +进程内 pub/sub:每个用户的活跃 SSE 订阅者用一个 asyncio.Queue 排队接收事件; +其他业务路径(approvals/tasks/...)通过 emit() 发布事件。 + +⚠️ 单进程范围:mcp-server 多副本部署下,副本 A 上的订阅者**收不到**副本 B 上 +emit 的事件。MVP 阶段可接受(cc-haha 单连接);生产化时换成 Redis pub/sub +或 NATS(mcp-server 已部署 NATS)即可,emit/subscribe 接口不变。 +""" +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Optional, Set + + +class _EventBus: + """每用户独立队列的内存 pub/sub。""" + + def __init__(self): + # user_id (str) → set of asyncio.Queue + self._subscribers: Dict[str, Set[asyncio.Queue]] = {} + self._lock = asyncio.Lock() + + async def subscribe(self, user_id: str) -> asyncio.Queue: + """订阅指定用户的事件流。返回一个新的 Queue。""" + queue: asyncio.Queue = asyncio.Queue(maxsize=256) + async with self._lock: + self._subscribers.setdefault(user_id, set()).add(queue) + return queue + + async def unsubscribe(self, user_id: str, queue: asyncio.Queue) -> None: + async with self._lock: + subs = self._subscribers.get(user_id) + if subs: + subs.discard(queue) + if not subs: + self._subscribers.pop(user_id, None) + + async def emit(self, user_id: str, event_type: str, data: Dict[str, Any]) -> int: + """向指定用户的所有订阅者推送事件。返回送达的订阅者数。 + + 队列已满时 drop(不阻塞 emit;客户端断重连后会重拉一次状态)。 + """ + payload = {"event": event_type, "data": data} + delivered = 0 + async with self._lock: + subs = list(self._subscribers.get(user_id, [])) + for q in subs: + try: + q.put_nowait(payload) + delivered += 1 + except asyncio.QueueFull: + pass + return delivered + + def subscriber_count(self, user_id: Optional[str] = None) -> int: + if user_id is not None: + return len(self._subscribers.get(user_id, set())) + return sum(len(s) for s in self._subscribers.values()) + + +# 单例 +_default_bus: Optional[_EventBus] = None + + +def get_event_bus() -> _EventBus: + global _default_bus + if _default_bus is None: + _default_bus = _EventBus() + return _default_bus diff --git a/services/mcp-server/app/heicode_client.py b/services/mcp-server/app/heicode_client.py new file mode 100644 index 0000000..6fb9461 --- /dev/null +++ b/services/mcp-server/app/heicode_client.py @@ -0,0 +1,263 @@ +""" +Heicode NewAPI 出站客户端(P4) + +mcp-server 通过 admin service token 调用 Heicode NewAPI(`code.xinghanlab.com`), +拉取用户视角的元数据(余额 / 模型 / 用量 / 调用日志),用于 Manager 控制台展示。 + +关键约束(NewAPI middleware/auth.go 强制): +- `Authorization: Bearer ` — admin 身份 +- `New-Api-User: ` — **必须等于** access token 对应的用户 id(CSRF 检查) + +→ 这意味着 admin token **不能"切身份"调 user-self 路由**(`/api/user/self*`), + 必须用 admin 路由 + `?user_id=X` query 参数定位目标用户。 + +→ "把当前 mcp-server 用户的 email 解析成 heicode 本地 user_id" 通过 admin search 实现: + `GET /api/user/search?keyword=` → cache 结果。 + +依据: +- Docs/Heicode-对接进度与待办.md §2.3 + §2.3.1(Heicode 团队 2026-05-07 决策 B 方案) +- Docs/Heicode-完整调用流程图.md §2.5 4 路径架构 +""" +from __future__ import annotations + +import asyncio +from typing import Any, Dict, List, Optional, Tuple + +import httpx +import structlog + +from config import settings + + +logger = structlog.get_logger(__name__) + + +class HeicodeNewAPIError(Exception): + """Heicode NewAPI 调用失败""" + def __init__(self, message: str, status_code: int = 0, body: Any = None): + super().__init__(message) + self.status_code = status_code + self.body = body + + +class HeicodeNewAPIClient: + """Heicode NewAPI HTTP 客户端(admin token-based)""" + + def __init__( + self, + base_url: Optional[str] = None, + admin_token: Optional[str] = None, + admin_user_id: Optional[str] = None, + timeout: Optional[int] = None, + ): + self.base_url = (base_url or settings.heicode_newapi_base_url).rstrip("/") + self.admin_token = admin_token or settings.heicode_newapi_admin_token + self.admin_user_id = admin_user_id or settings.heicode_newapi_admin_user_id + self.timeout = timeout or settings.heicode_newapi_timeout + + # 简单内存缓存:email → heicode local user_id(int) + # TTL 30 分钟,命中即用 + self._user_id_cache: Dict[str, Tuple[int, float]] = {} + self._cache_ttl_sec = 1800 + self._cache_lock = asyncio.Lock() + + def is_configured(self) -> bool: + """判断是否已配好可用的 admin token + user_id""" + return bool(self.admin_token and self.admin_user_id) + + def _admin_headers(self, request_id: Optional[str] = None) -> Dict[str, str]: + """构造 admin 调用的 headers(含 Authorization + New-Api-User)""" + if not self.is_configured(): + raise HeicodeNewAPIError( + "Heicode NewAPI admin token / admin_user_id 未配置 " + "(设置 HEICODE_NEWAPI_SERVICE_TOKEN + HEICODE_NEWAPI_ADMIN_USER_ID)", + status_code=503, + ) + h = { + "Authorization": f"Bearer {self.admin_token}", + "New-Api-User": str(self.admin_user_id), + "Content-Type": "application/json", + "Accept": "application/json", + } + if request_id: + h["X-Request-Id"] = request_id + return h + + async def _get( + self, + path: str, + params: Optional[Dict[str, Any]] = None, + request_id: Optional[str] = None, + ) -> Dict[str, Any]: + """统一 GET 调用""" + url = f"{self.base_url}{path}" + headers = self._admin_headers(request_id) + async with httpx.AsyncClient(timeout=self.timeout) as client: + try: + resp = await client.get(url, headers=headers, params=params) + except httpx.HTTPError as e: + logger.error( + "heicode_newapi_request_failed", + method="GET", url=url, error=str(e), + ) + raise HeicodeNewAPIError(f"请求 Heicode NewAPI 失败: {e}", status_code=502) + + try: + body = resp.json() + except Exception: + body = {"raw": resp.text[:500]} + + if resp.status_code >= 400: + logger.warning( + "heicode_newapi_non_2xx", + method="GET", url=url, status=resp.status_code, + body_preview=str(body)[:200], + ) + raise HeicodeNewAPIError( + f"Heicode NewAPI HTTP {resp.status_code}: {body}", + status_code=resp.status_code, + body=body, + ) + + # NewAPI 响应惯例:{"success": bool, "message": str, "data": ...} + if isinstance(body, dict) and body.get("success") is False: + raise HeicodeNewAPIError( + f"Heicode NewAPI business error: {body.get('message', '')}", + status_code=resp.status_code, + body=body, + ) + + return body if isinstance(body, dict) else {"raw": body} + + # ==================== email → heicode user_id 解析 ==================== + + async def resolve_user_id_by_email(self, email: str) -> Optional[int]: + """通过 admin search 把 email 解析成 heicode 本地 user_id(int) + + 命中缓存 → 直接返回;否则调 admin /api/user/search?keyword= 查询。 + """ + import time + if not email: + return None + email_key = email.strip().lower() + now = time.time() + + async with self._cache_lock: + cached = self._user_id_cache.get(email_key) + if cached and (now - cached[1]) < self._cache_ttl_sec: + return cached[0] + + try: + body = await self._get( + "/api/user/search", + params={"keyword": email_key, "group": ""}, + ) + except HeicodeNewAPIError as e: + logger.warning( + "heicode_user_resolve_failed", + email=email_key, error=str(e), + ) + return None + + # NewAPI 返回结构通常为 {"data": {"items": [...]}} 或 {"data": [...]} + data = body.get("data") if isinstance(body, dict) else None + items: List[Dict[str, Any]] = [] + if isinstance(data, list): + items = data + elif isinstance(data, dict): + items = data.get("items") or data.get("users") or [] + + # 精确匹配 email + for u in items: + if not isinstance(u, dict): + continue + if (u.get("email") or "").strip().lower() == email_key: + uid = u.get("id") + if isinstance(uid, int) and uid > 0: + async with self._cache_lock: + self._user_id_cache[email_key] = (uid, now) + return uid + + # 找不到精确匹配 + return None + + # ==================== 用户视角元数据查询(admin 路由 + user_id 参数) ==================== + + async def get_user_info(self, heicode_user_id: int, request_id: Optional[str] = None) -> Dict[str, Any]: + """GET /api/user/{id} — 用户详情(含 quota/group/status/...)""" + body = await self._get(f"/api/user/{heicode_user_id}", request_id=request_id) + return body.get("data") if isinstance(body, dict) else body + + async def list_user_models( + self, heicode_user_id: int, + request_id: Optional[str] = None, + ) -> List[Any]: + """GET /api/user/{id}/models — 按用户 id 列出该用户可用模型清单。 + + 修订(2026-05-08,按 Heicode §7.11.2):原 `/api/models` 是渠道仪表盘 + 视角(key 是 channelId),mcp-server-service 没渠道,自然空。改用 + `/api/user/{id}/models`(admin 替指定用户查),返回 string[]。 + """ + body = await self._get( + f"/api/user/{heicode_user_id}/models", + request_id=request_id, + ) + data = body.get("data") if isinstance(body, dict) else body + if isinstance(data, list): + return data + if isinstance(data, dict): + return data.get("items") or data.get("models") or [] + return [] + + async def get_user_quota_dates( + self, heicode_user_id: int, + days: int = 30, + request_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """GET /api/data/users — 按用户 id 拉取最近 N 天的用量""" + body = await self._get( + "/api/data/users", + params={"user_id": heicode_user_id, "default_time": str(days)}, + request_id=request_id, + ) + data = body.get("data") if isinstance(body, dict) else None + if isinstance(data, list): + return data + if isinstance(data, dict): + return data.get("items") or [] + return [] + + async def get_user_logs( + self, heicode_user_id: int, + page: int = 1, page_size: int = 50, + request_id: Optional[str] = None, + ) -> List[Dict[str, Any]]: + """GET /api/log/?user_id=X — 用户调用日志(admin 路由)""" + body = await self._get( + "/api/log/", + params={ + "p": page, + "page_size": page_size, + "user_id": heicode_user_id, + "type": 0, # 0=全部 + }, + request_id=request_id, + ) + data = body.get("data") if isinstance(body, dict) else None + if isinstance(data, list): + return data + if isinstance(data, dict): + return data.get("items") or [] + return [] + + +# ==================== 单例 ==================== + +_default_client: Optional[HeicodeNewAPIClient] = None + + +def get_heicode_client() -> HeicodeNewAPIClient: + global _default_client + if _default_client is None: + _default_client = HeicodeNewAPIClient() + return _default_client diff --git a/services/mcp-server/app/routes/__init__.py b/services/mcp-server/app/routes/__init__.py index c1cd6cb..0350377 100644 --- a/services/mcp-server/app/routes/__init__.py +++ b/services/mcp-server/app/routes/__init__.py @@ -14,6 +14,10 @@ from . import ( external_tools, # 外部数据工具管理 paypal, # PayPal 支付集成 resources, resource_grants, # Heicode P1:资源绑定与授权 + heicode_proxy, # Heicode P4:NewAPI 用户视角元数据透传 + agnet_stub, # Heicode P5:Agnet 平台本地 stub(12 个 /api/agnet/* 端点) + heicode_tasks, # Heicode 任务编排(5 个 /api/user/tasks/* 端点) + heicode_events, # Heicode §7.8.3:SSE 单通道 + Approval REST ) @@ -53,5 +57,13 @@ def register_routes(app: FastAPI) -> None: # Heicode P1: 资源绑定与授权 resources.router, resource_grants.router, + # Heicode P4: NewAPI 元数据透传 + heicode_proxy.router, + # Heicode P5: Agnet 平台本地 stub + agnet_stub.router, + # Heicode 任务编排 + heicode_tasks.router, + # Heicode §7.8.3 SSE + Approval + heicode_events.router, ): app.include_router(router) diff --git a/services/mcp-server/app/routes/agnet_stub.py b/services/mcp-server/app/routes/agnet_stub.py new file mode 100644 index 0000000..5786c1e --- /dev/null +++ b/services/mcp-server/app/routes/agnet_stub.py @@ -0,0 +1,722 @@ +""" +Heicode P5 — Manager 侧 /api/agnet/* 本地 stub + +完全按 heicode 仓库 docs/integration/agnet-platform-request-contract.md 的字段形态返回 mock 数据, +便于 cc-haha 客户端 / heicode web 前端在 agent-manager 真正落地前先联调主流程。 + +存储:内存 dict,重启清空。不调真实 K8s。 +鉴权:复用 require_auth(与已上线 4 个登录接口同一机制)。 +真实 Agnet 平台落地后,把内部 _store + 假数据生成切换成 outbound HTTP client 即可, +对前端 0 改动(路径 / 字段 / 错误码完全相同)。 + +12 个端点: + 1. POST /api/agnet/deployments + 2. GET /api/agnet/deployments + 3. GET /api/agnet/deployments/{id} + 4. POST /api/agnet/deployments/{id}/stop + 5. GET /api/agnet/deployments/{id}/logs + 6. GET /api/agnet/deployments/{id}/logs/stream (SSE) + 7. GET /api/agnet/projects/{binding_scope}/dashboard-snapshot + 8. GET /api/agnet/deployments/{id}/metrics + 9. GET /api/agnet/deployments/{id}/events + 10. GET /api/agnet/audit-logs + 11. POST /api/agnet/sk-snapshots/resolve + 12. GET /api/agnet/deployments/{id}/sk-snapshots +""" +from __future__ import annotations + +import asyncio +import json +import secrets +import time +import uuid +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from fastapi.responses import StreamingResponse + +from app.auth import require_auth +from app.routes.resources import reject_sensitive_keys +from app.schemas import SuccessResponse + + +router = APIRouter(prefix="/api/agnet", tags=["Heicode P5 Agnet Stub"]) + + +# ==================== 内存 store ==================== + +class _AgnetStubStore: + """内存存储所有 stub 数据。多副本部署下每副本独立,仅供联调。""" + def __init__(self): + self.deployments: Dict[str, Dict[str, Any]] = {} + self.events: Dict[str, List[Dict[str, Any]]] = {} # deployment_id → events + self.logs: Dict[str, List[Dict[str, Any]]] = {} # deployment_id → logs + self.sk_snapshots: Dict[str, List[Dict[str, Any]]] = {} # deployment_id → snapshots + self.audit_logs: List[Dict[str, Any]] = [] + self.idempotency_cache: Dict[str, Dict[str, Any]] = {} # idempotency_key → response + self.lock = asyncio.Lock() + + +_store = _AgnetStubStore() + + +def _now_iso() -> str: + return datetime.utcnow().isoformat(timespec="microseconds") + "Z" + + +def _new_id(prefix: str) -> str: + return f"{prefix}_{secrets.token_hex(6)}" + + +def _correlation_id(request: Request, fallback_field: Optional[str] = None) -> str: + return ( + request.headers.get("X-Correlation-Id") + or request.headers.get("X-Request-Id") + or fallback_field + or str(uuid.uuid4()) + ) + + +def _err(http_status: int, code: str, message: str, request_id: Optional[str] = None): + detail = {"code": code, "message": message} + if request_id: + detail["request_id"] = request_id + raise HTTPException(status_code=http_status, detail=detail) + + +# ==================== 共享:payload 校验 ==================== + +ALLOWED_PROVIDERS = {"newapi", "litellm"} +ALLOWED_RISK_LEVELS = {"low", "medium", "high"} +ALLOWED_RESOURCE_TYPES_DEPLOYMENT = { + "git", "sk", "project_doc", "cloud_account", "cloud_resource", + "model_gateway_token", # P5 新增(按 §3a) +} +DEPLOYMENT_NON_TERMINAL = {"accepted", "pending", "running", "stopping", "processing"} +DEPLOYMENT_TERMINAL = {"stopped", "completed", "failed", "cancelled"} + + +def _validate_orchestration_plan(op: Dict[str, Any], request_id: str) -> None: + """完整校验 orchestration_plan 结构,按契约 §2.3 + §8。""" + if not isinstance(op, dict): + _err(400, "POLICY_REJECTED", "orchestration_plan 必须是 object", request_id) + + required_fields = [ + "intent_id", "template_hint", "objective", "risk_level", + "budget", "metadata", "agents", + ] + for f in required_fields: + if f not in op or op[f] in (None, "", [], {}): + _err(400, "POLICY_REJECTED", + f"orchestration_plan.{f} 必填", request_id) + + if op["risk_level"] not in ALLOWED_RISK_LEVELS: + _err(400, "POLICY_REJECTED", + f"risk_level 必须是 {sorted(ALLOWED_RISK_LEVELS)} 之一", request_id) + + bud = op.get("budget") or {} + for f in ("max_tokens", "max_cost_usd", "max_duration_sec"): + if f not in bud: + _err(400, "POLICY_REJECTED", f"budget.{f} 必填", request_id) + + metadata = op.get("metadata") or {} + if not metadata.get("correlation_id"): + _err(400, "POLICY_REJECTED", + "metadata.correlation_id 必填", request_id) + + # billing_context.provider 必须是 newapi 或 litellm + bc = op.get("billing_context") or {} + provider = bc.get("provider") + if provider is not None and provider not in ALLOWED_PROVIDERS: + _err(400, "POLICY_REJECTED", + f"billing_context.provider 必须是 {sorted(ALLOWED_PROVIDERS)} 之一,收到 '{provider}'", + request_id) + + # agents[] 至少 1 个 + agents = op.get("agents") or [] + if not isinstance(agents, list) or len(agents) == 0: + _err(400, "POLICY_REJECTED", + "orchestration_plan.agents 至少 1 项", request_id) + + # 校验每个 agent + for i, agent in enumerate(agents): + if not isinstance(agent, dict): + _err(400, "POLICY_REJECTED", + f"agents[{i}] 必须是 object", request_id) + if not agent.get("role_template"): + _err(400, "POLICY_REJECTED", + f"agents[{i}].role_template 必填", request_id) + if not agent.get("goal"): + _err(400, "POLICY_REJECTED", + f"agents[{i}].goal 必填", request_id) + + # default_model_id 若设置必须 ∈ allowed_model_ids + constraints = op.get("constraints") or {} + allowed = constraints.get("allowed_model_ids") or [] + dmi = agent.get("default_model_id") + if dmi and allowed and dmi not in allowed: + _err(403, "MODEL_NOT_ALLOWED", + f"agents[{i}].default_model_id='{dmi}' 不在 allowed_model_ids 内", + request_id) + + # resource_grants[] 校验 + grants = agent.get("resource_grants") or [] + for j, g in enumerate(grants): + if not isinstance(g, dict): + _err(422, "RESOURCE_GRANT_INVALID", + f"agents[{i}].resource_grants[{j}] 必须是 object", request_id) + for f in ("grant_id", "resource_id", "resource_type", "user_id", + "binding_scope", "target_role", "target_agent_ref", + "permission_scope", "status"): + if f not in g or g[f] in (None, ""): + _err(422, "RESOURCE_GRANT_INVALID", + f"agents[{i}].resource_grants[{j}].{f} 必填", request_id) + if g["resource_type"] not in ALLOWED_RESOURCE_TYPES_DEPLOYMENT: + _err(422, "RESOURCE_GRANT_INVALID", + f"resource_grants[{j}].resource_type 不合法: {g['resource_type']}", + request_id) + # 凭据型必填 secret_ref + if g["resource_type"] != "project_doc": + if not g.get("secret_ref"): + _err(422, "RESOURCE_GRANT_SECRET_REF_REQUIRED", + f"agents[{i}].resource_grants[{j}].secret_ref 必填({g['resource_type']})", + request_id) + # role 一致 + if g.get("target_role") and g["target_role"] != agent["role_template"]: + _err(422, "RESOURCE_GRANT_INVALID", + f"resource_grants[{j}].target_role 必须等于 agents[{i}].role_template", + request_id) + + # high risk 必须有 approval_id + if op["risk_level"] == "high": + has_approval = False + for g in grants: + if (g.get("constraints") or {}).get("approval_id"): + has_approval = True + break + if (g.get("audit") or {}).get("approval_id"): + has_approval = True + break + if not has_approval: + _err(403, "POLICY_REJECTED", + "risk_level=high 必须在 resource_grants 的 constraints 或 audit 中携带 approval_id", + request_id) + + # 敏感字段递归扫描 — **仅** scan 契约 §8.3 列出的字段:metadata / constraints / audit + # 不能扫 budget(含 max_tokens)/ billing_context(含 newapi_token_or_group_quota_ref)/ + # agent_runtime / orchestration_plan 顶层(避免误杀合法字段名) + if isinstance(op.get("metadata"), dict): + reject_sensitive_keys(op["metadata"], "orchestration_plan.metadata.") + for i, agent in enumerate(op.get("agents", [])): + for j, g in enumerate(agent.get("resource_grants") or []): + base = f"orchestration_plan.agents[{i}].resource_grants[{j}]" + for sub in ("metadata", "constraints", "audit"): + v = g.get(sub) + if isinstance(v, dict): + reject_sensitive_keys(v, f"{base}.{sub}.") + + +# ==================== 1. POST /deployments ==================== + +@router.post("/deployments", response_model=SuccessResponse) +async def create_deployment( + payload: Dict[str, Any], + request: Request, + principal: dict = Depends(require_auth), +): + request_id = _correlation_id(request) + idempotency_key = request.headers.get("Idempotency-Key") + + # 幂等:同 key 命中直接返回缓存结果 + if idempotency_key: + async with _store.lock: + cached = _store.idempotency_cache.get(idempotency_key) + if cached: + return SuccessResponse(data=cached) + + op = payload.get("orchestration_plan") + if not op: + _err(400, "POLICY_REJECTED", "请求体缺少 orchestration_plan", request_id) + + _validate_orchestration_plan(op, request_id) + + # 创建 deployment + deployment_id = _new_id("dep") + now = _now_iso() + user_id = (op.get("user_context") or {}).get("user_id") or \ + request.headers.get("X-User-Id") + binding_scope = (op.get("user_context") or {}).get("channel_id") or \ + request.headers.get("X-Binding-Scope") or "default" + correlation_id_field = (op.get("metadata") or {}).get("correlation_id", request_id) + + instances = [] + for agent in op.get("agents", []): + instances.append({ + "instance_id": _new_id("agi"), + "role": agent["role_template"], + "phase": "pending", + }) + + deployment = { + "deployment_id": deployment_id, + "status": "accepted", + "phase": "pending", + "agent_instances": instances, + "user_id": user_id, + "binding_scope": binding_scope, + "correlation_id": correlation_id_field, + "intent_id": op.get("intent_id"), + "risk_level": op.get("risk_level"), + "budget": op.get("budget"), + "billing_context": op.get("billing_context"), + "agent_runtime": op.get("agent_runtime"), + "resource_grants_summary": [ + { + "grant_id": g.get("grant_id"), + "resource_type": g.get("resource_type"), + "binding_scope": g.get("binding_scope"), + "target_role": g.get("target_role"), + "permission_scope": g.get("permission_scope"), + "status": g.get("status"), + } + for agent in op.get("agents", []) + for g in (agent.get("resource_grants") or []) + ], + "budget_consumed": {"tokens_used": 0, "cost_usd": 0.0, "duration_sec": 0}, + "last_error": None, + "created_at": now, + "updated_at": now, + } + + # seed events + events = [{ + "event_id": _new_id("evt"), + "event": "deployment.accepted", + "schema_version": 1, + "user_id": user_id, + "channel_id": binding_scope, + "binding_scope": binding_scope, + "deployment_id": deployment_id, + "correlation_id": correlation_id_field, + "occurred_at": now, + }] + for ins in instances: + events.append({ + "event_id": _new_id("evt"), + "event": "instance.phase_changed", + "schema_version": 1, + "user_id": user_id, + "channel_id": binding_scope, + "binding_scope": binding_scope, + "deployment_id": deployment_id, + "instance_id": ins["instance_id"], + "phase": "pending", + "correlation_id": correlation_id_field, + "occurred_at": now, + }) + + # seed logs(脱敏占位) + logs = [{ + "log_id": _new_id("log"), + "deployment_id": deployment_id, + "agent_instance_id": instances[0]["instance_id"] if instances else None, + "stream": "system", + "level": "info", + "message": "[stub] deployment accepted, awaiting K8s bring-up", + "redacted": True, + "occurred_at": now, + }] + + # audit + audit_entry = { + "audit_id": _new_id("aud"), + "actor": "manager", + "action": "deployment.accepted", + "resource": deployment_id, + "user_id": user_id, + "channel_id": binding_scope, + "binding_scope": binding_scope, + "request_id": request_id, + "correlation_id": correlation_id_field, + "result": "ok", + "occurred_at": now, + } + + async with _store.lock: + _store.deployments[deployment_id] = deployment + _store.events[deployment_id] = events + _store.logs[deployment_id] = logs + _store.sk_snapshots[deployment_id] = [] + _store.audit_logs.append(audit_entry) + + response_data = { + "deployment_id": deployment_id, + "status": "accepted", + "agent_instances": instances, + } + if idempotency_key: + _store.idempotency_cache[idempotency_key] = response_data + + return SuccessResponse(data=response_data) + + +# ==================== 2-4. 部署列表 / 详情 / 停止 ==================== + +@router.get("/deployments", response_model=SuccessResponse) +async def list_deployments( + request: Request, + principal: dict = Depends(require_auth), + user_id: Optional[str] = Query(None), + binding_scope: Optional[str] = Query(None), + status_filter: Optional[str] = Query(None, alias="status"), + limit: int = Query(50, ge=1, le=500), + cursor: Optional[str] = Query(None), +): + async with _store.lock: + items = [] + for d in _store.deployments.values(): + if user_id and d.get("user_id") != user_id: + continue + if binding_scope and d.get("binding_scope") != binding_scope: + continue + if status_filter and d.get("status") != status_filter: + continue + items.append({ + "deployment_id": d["deployment_id"], + "status": d["status"], + "phase": d["phase"], + "user_id": d.get("user_id"), + "binding_scope": d.get("binding_scope"), + "created_at": d["created_at"], + "updated_at": d["updated_at"], + }) + items.sort(key=lambda x: x["created_at"], reverse=True) + items = items[:limit] + return SuccessResponse(data={"items": items, "total": len(items)}) + + +@router.get("/deployments/{deployment_id}", response_model=SuccessResponse) +async def get_deployment( + deployment_id: str, + request: Request, + principal: dict = Depends(require_auth), +): + async with _store.lock: + d = _store.deployments.get(deployment_id) + if d is None: + _err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request)) + return SuccessResponse(data=d) + + +@router.post("/deployments/{deployment_id}/stop", response_model=SuccessResponse) +async def stop_deployment( + deployment_id: str, + request: Request, + principal: dict = Depends(require_auth), + payload: Optional[Dict[str, Any]] = None, +): + request_id = _correlation_id(request) + async with _store.lock: + d = _store.deployments.get(deployment_id) + if d is None: + _err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", request_id) + # 幂等:已 stopped 重复 stop 仍返 200 + if d["status"] == "stopped": + return SuccessResponse(data={ + "deployment_id": deployment_id, + "status": "stopped", + }) + # 终态(completed 等)拒绝 + if d["status"] in DEPLOYMENT_TERMINAL and d["status"] != "stopped": + _err(409, "DEPLOYMENT_CONFLICT", + f"部署已进入终态 {d['status']},不能停止", request_id) + d["status"] = "stopped" + d["phase"] = "stopped" + d["updated_at"] = _now_iso() + for ins in d.get("agent_instances", []): + ins["phase"] = "stopped" + # event + _store.events[deployment_id].append({ + "event_id": _new_id("evt"), + "event": "deployment.stopped", + "schema_version": 1, + "user_id": d.get("user_id"), + "channel_id": d.get("binding_scope"), + "binding_scope": d.get("binding_scope"), + "deployment_id": deployment_id, + "correlation_id": d.get("correlation_id"), + "occurred_at": d["updated_at"], + }) + # audit + _store.audit_logs.append({ + "audit_id": _new_id("aud"), + "actor": "manager", + "action": "deployment.stop", + "resource": deployment_id, + "user_id": d.get("user_id"), + "binding_scope": d.get("binding_scope"), + "request_id": request_id, + "correlation_id": d.get("correlation_id"), + "result": "ok", + "occurred_at": d["updated_at"], + }) + + return SuccessResponse(data={ + "deployment_id": deployment_id, + "status": "stopped", + }) + + +# ==================== 5-6. 日志 + SSE ==================== + +@router.get("/deployments/{deployment_id}/logs", response_model=SuccessResponse) +async def list_logs( + deployment_id: str, + request: Request, + principal: dict = Depends(require_auth), + agent_instance_id: Optional[str] = Query(None), + stream: Optional[str] = Query(None), + since: Optional[str] = Query(None), + limit: int = Query(200, ge=1, le=1000), + cursor: Optional[str] = Query(None), +): + async with _store.lock: + if deployment_id not in _store.deployments: + _err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request)) + logs = list(_store.logs.get(deployment_id, [])) + # 过滤 + if agent_instance_id: + logs = [l for l in logs if l.get("agent_instance_id") == agent_instance_id] + if stream: + logs = [l for l in logs if l.get("stream") == stream] + if since: + logs = [l for l in logs if (l.get("occurred_at") or "") >= since] + return SuccessResponse(data={ + "items": logs[:limit], + "next_cursor": None, + "total": len(logs), + }) + + +@router.get("/deployments/{deployment_id}/logs/stream") +async def stream_logs( + deployment_id: str, + request: Request, + principal: dict = Depends(require_auth), + agent_instance_id: Optional[str] = Query(None), +): + async with _store.lock: + if deployment_id not in _store.deployments: + _err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request)) + + async def gen(): + # 发送 2 条 mock log + 1 个 heartbeat + done + for i in range(2): + line = { + "log_id": _new_id("log"), + "deployment_id": deployment_id, + "level": "info", + "message": f"[stub-stream] mock log line {i+1}", + "occurred_at": _now_iso(), + } + yield f"event: log\ndata: {json.dumps(line)}\n\n" + await asyncio.sleep(0.2) + yield f"event: heartbeat\ndata: {{}}\n\n" + await asyncio.sleep(0.2) + yield f"event: done\ndata: {{}}\n\n" + + return StreamingResponse(gen(), media_type="text/event-stream") + + +# ==================== 7-8. 监控快照 + metrics ==================== + +@router.get("/projects/{binding_scope}/dashboard-snapshot", response_model=SuccessResponse) +async def dashboard_snapshot( + binding_scope: str, + request: Request, + principal: dict = Depends(require_auth), + window: str = Query("1h"), +): + async with _store.lock: + deps = [d for d in _store.deployments.values() if d.get("binding_scope") == binding_scope] + phase_dist = {"pending": 0, "running": 0, "stopped": 0, "failed": 0} + for d in deps: + ph = d.get("phase", "pending") + if ph not in phase_dist: + phase_dist[ph] = 0 + phase_dist[ph] += 1 + + return SuccessResponse(data={ + "project_id": binding_scope, + "binding_scope": binding_scope, + "active_instances": sum(1 for d in deps if d.get("status") in DEPLOYMENT_NON_TERMINAL), + "phase_distribution": phase_dist, + "failure_rate_1h": 0.0, + "avg_task_duration": 0.0, + "budget": {"tokens_used": 0, "cost_usd": 0.0, "duration_sec": 0}, + "resource_usage": { + "cpu_millicores": 0, "memory_mb": 0, + "network_rx_bytes": 0, "network_tx_bytes": 0, + }, + "updated_at": _now_iso(), + }) + + +@router.get("/deployments/{deployment_id}/metrics", response_model=SuccessResponse) +async def deployment_metrics( + deployment_id: str, + request: Request, + principal: dict = Depends(require_auth), + window: str = Query("15m"), + step: str = Query("60s"), +): + async with _store.lock: + if deployment_id not in _store.deployments: + _err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request)) + + now = _now_iso() + series = [ + {"metric": "tokens_used", "unit": "count", "points": [[now, 0]]}, + {"metric": "cost_usd", "unit": "usd", "points": [[now, 0.0]]}, + {"metric": "duration_sec", "unit": "count", "points": [[now, 0]]}, + {"metric": "cpu_millicores", "unit": "millicore", "points": [[now, 0]]}, + {"metric": "memory_mb", "unit": "mb", "points": [[now, 0]]}, + {"metric": "restart_count", "unit": "count", "points": [[now, 0]]}, + {"metric": "tool_call_count", "unit": "count", "points": [[now, 0]]}, + {"metric": "error_count", "unit": "count", "points": [[now, 0]]}, + {"metric": "queue_latency_ms", "unit": "ms", "points": [[now, 0]]}, + ] + return SuccessResponse(data={ + "deployment_id": deployment_id, + "window": window, + "step": step, + "series": series, + }) + + +# ==================== 9-10. 事件 + 审计 ==================== + +@router.get("/deployments/{deployment_id}/events", response_model=SuccessResponse) +async def list_events( + deployment_id: str, + request: Request, + principal: dict = Depends(require_auth), + since: Optional[str] = Query(None), + limit: int = Query(200, ge=1, le=1000), + cursor: Optional[str] = Query(None), +): + async with _store.lock: + if deployment_id not in _store.deployments: + _err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request)) + events = list(_store.events.get(deployment_id, [])) + if since: + events = [e for e in events if (e.get("occurred_at") or "") >= since] + return SuccessResponse(data={ + "items": events[:limit], + "next_cursor": None, + "total": len(events), + }) + + +@router.get("/audit-logs", response_model=SuccessResponse) +async def list_audit_logs( + request: Request, + principal: dict = Depends(require_auth), + user_id: Optional[str] = Query(None), + binding_scope: Optional[str] = Query(None), + actor: Optional[str] = Query(None), + action: Optional[str] = Query(None), + since: Optional[str] = Query(None), + limit: int = Query(200, ge=1, le=1000), + cursor: Optional[str] = Query(None), +): + async with _store.lock: + items = list(_store.audit_logs) + if user_id: + items = [a for a in items if a.get("user_id") == user_id] + if binding_scope: + items = [a for a in items if a.get("binding_scope") == binding_scope] + if actor: + items = [a for a in items if a.get("actor") == actor] + if action: + items = [a for a in items if a.get("action") == action] + if since: + items = [a for a in items if (a.get("occurred_at") or "") >= since] + items.sort(key=lambda a: a.get("occurred_at", ""), reverse=True) + return SuccessResponse(data={ + "items": items[:limit], + "next_cursor": None, + "total": len(items), + }) + + +# ==================== 11-12. SK 快照 ==================== + +@router.post("/sk-snapshots/resolve", response_model=SuccessResponse) +async def resolve_sk_snapshot( + payload: Dict[str, Any], + request: Request, + principal: dict = Depends(require_auth), +): + request_id = _correlation_id(request) + deployment_id = payload.get("deployment_id") + if not deployment_id: + _err(400, "POLICY_REJECTED", "deployment_id 必填", request_id) + async with _store.lock: + d = _store.deployments.get(deployment_id) + if d is None: + _err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", request_id) + snap = { + "snapshot_id": _new_id("sks"), + "deployment_id": deployment_id, + "user_id": d.get("user_id"), + "binding_scope": d.get("binding_scope"), + "source_type": "git", + "source_ref": "main:skills/heicode/**@stub-checksum", + "artifact_ref": f"artifact://stub/{deployment_id}/sks", + "checksum": "sha256:stub-redacted", + "status": "ready", + "resolved_at": _now_iso(), + } + _store.sk_snapshots[deployment_id].append(snap) + # event + _store.events[deployment_id].append({ + "event_id": _new_id("evt"), + "event": "sk_snapshot_refreshed", + "schema_version": 1, + "user_id": d.get("user_id"), + "binding_scope": d.get("binding_scope"), + "deployment_id": deployment_id, + "snapshot_id": snap["snapshot_id"], + "occurred_at": snap["resolved_at"], + }) + return SuccessResponse(data={ + "deployment_id": deployment_id, + "items": [snap], + "total": 1, + }) + + +@router.get("/deployments/{deployment_id}/sk-snapshots", response_model=SuccessResponse) +async def list_sk_snapshots( + deployment_id: str, + request: Request, + principal: dict = Depends(require_auth), + user_id: Optional[str] = Query(None), + binding_scope: Optional[str] = Query(None), + source_type: Optional[str] = Query(None), + limit: int = Query(100, ge=1, le=500), + cursor: Optional[str] = Query(None), +): + async with _store.lock: + if deployment_id not in _store.deployments: + _err(404, "NOT_FOUND", f"部署 {deployment_id} 不存在", _correlation_id(request)) + items = list(_store.sk_snapshots.get(deployment_id, [])) + if source_type: + items = [s for s in items if s.get("source_type") == source_type] + return SuccessResponse(data={ + "items": items[:limit], + "next_cursor": None, + "total": len(items), + }) diff --git a/services/mcp-server/app/routes/auth.py b/services/mcp-server/app/routes/auth.py index 04e690f..41d0fbf 100644 --- a/services/mcp-server/app/routes/auth.py +++ b/services/mcp-server/app/routes/auth.py @@ -35,8 +35,9 @@ from app.schemas import ( RegenerateAPIKeyResponse, UserCreate, ) -from app.email_verification import verify_code, send_and_store_verification_code, check_rate_limit, send_password_reset_code +from app.email_verification import verify_code, peek_verification_code, send_and_store_verification_code, check_rate_limit, send_password_reset_code from app.audit import log_audit_event +from pydantic import BaseModel from app.agent_manager_client import get_agent_manager_client, AgentManagerError from app.litellm_client import get_litellm_client, LiteLLMClientError from config import settings @@ -748,8 +749,10 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)): detail="该用户名已被使用" ) - # 3. 最后验证邮箱验证码(验证成功后会消耗验证码) - is_valid = await verify_code(req.email, req.verification_code) + # 3. 验证邮箱验证码(先 peek 不消费 —— 等 DB commit + 外部副作用全部成功 + # 后再调 verify_code 真消费。否则后续任何步骤失败,用户的验证码就被 + # 白白烧掉了,必须重新发码。 + is_valid = await peek_verification_code(req.email, req.verification_code) if not is_valid: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -815,7 +818,11 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)): ) user_id = new_user.id - + + # 跟踪本次注册在 LiteLLM 远端已经创建的 key —— commit 失败时回扫 delete, + # 避免远端泄漏 orphan key。注意:generate_key 是 fire-and-forget 网络副作 + # 用,DB rollback 不能撤销它,必须显式 delete。 + created_litellm_keys: list[str] = [] try: # 1. 创建余额记录,初始余额 20 元 balance = Balance( @@ -892,7 +899,9 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)): quantity=1 ) db.add(allocation) - except (AgentManagerError, Exception) as e: + except AgentManagerError as e: + # AgentManager 不可达是已知运维态,平台 Agent 分配跳过,注册主流程 + # 继续;其他异常(SQLAlchemy / 编程错误等)让外层 try 兜底回滚。 logger.warning(f"获取平台 Agent 模板失败,跳过平台 Agent 分配: {e}") # 4. 分配所有供应商模型 @@ -903,10 +912,21 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)): if channel.litellm_team_id: litellm_client = get_litellm_client() - + + # 跨 provider 去重:DB 唯一约束是 (tenant_id, model_name), + # 如果两个 ModelProvider 行的 supported_models 有重叠(例如都包含 + # taiji/gpt-4o-mini),不去重会触发 uq_tenant_model 导致整个事务 + # 失败 → 注册 500。先以 model_name 为键去重,第一个看到的 provider 胜出。 + seen_models: set[str] = set() for provider in providers: # 为每个供应商的每个模型创建 TenantModelKey for model_name in provider.supported_models: + if model_name in seen_models: + logger.debug( + f"模型 {model_name} 已在其他 provider 处理过,跳过" + ) + continue + seen_models.add(model_name) try: # 在 LiteLLM 中创建 Key key = await litellm_client.generate_key( @@ -926,9 +946,14 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)): } ) + # 远端 key 创建成功 —— 立刻登记到 created_litellm_keys, + # 任何后续失败(DB add/flush/commit)都能 best-effort + # 删除掉远端 orphan。 + created_litellm_keys.append(key.key) + # 加密存储 Key encrypted_key = litellm_client.encrypt_key(key.key) - + # 保存到数据库 tenant_key = TenantModelKey( tenant_id=user_id, @@ -954,30 +979,81 @@ async def register(req: UserCreate, db: AsyncSession = Depends(get_db)): tpm=provider.tpm or 10000, ) db.add(model_allocation) - except (LiteLLMClientError, Exception) as e: + except LiteLLMClientError as e: + # LiteLLM 远端 API 错误:单模型分配失败可接受,跳过该 + # 模型继续。注意 generate_key 已在 created_litellm_keys + # 追加之前抛出,所以这条路径不会有 orphan。 logger.warning(f"为模型 {model_name} 创建 LiteLLM Key 失败: {e}") # 继续处理其他模型 + # SQLAlchemy / 编程错误等其他异常:故意不 catch —— 让外层 try + # 接住执行 rollback + LiteLLM orphan 清理,避免静默返回 200 + # 但只分到一半 key 的「假成功」。 else: logger.warning(f"渠道 {TAIJI_CHANNEL_ID} 未配置 LiteLLM team,跳过模型分配") # 提交所有更改 await db.commit() await db.refresh(new_user) - + logger.info( f"用户注册成功并分配默认资源", user_id=str(user_id), email=req.email, - channel_id=str(TAIJI_CHANNEL_ID) + channel_id=str(TAIJI_CHANNEL_ID), + litellm_key_count=len(created_litellm_keys), ) - + except Exception as e: + # 1. DB 回滚(new_user / balance / quota / TenantModelKey 全部撤销) await db.rollback() - logger.error(f"用户注册失败: {e}") + + # 2. 关键:DB 回滚撤不掉 LiteLLM 远端已创建的 key —— 必须显式逐个 delete。 + # best-effort:每个 delete 都 try/except,避免一个失败阻塞剩下的清理。 + if created_litellm_keys: + logger.warning( + "注册失败 —— 开始清理 LiteLLM 远端 orphan key", + email=req.email, + orphan_count=len(created_litellm_keys), + ) + try: + client = get_litellm_client() + for k in created_litellm_keys: + try: + await client.delete_key(k) + except Exception as del_err: + logger.error( + "清理 orphan LiteLLM key 失败(需人工跟进)", + key_prefix=k[:12] if k else None, + email=req.email, + error=str(del_err), + ) + except Exception as cleanup_err: + # 连 LiteLLM client 都拿不到:把 orphan key 写到日志里,运维兜底 + logger.error( + "无法获取 LiteLLM client 清理 orphan key", + email=req.email, + orphan_keys_first_chars=[k[:12] for k in created_litellm_keys], + error=str(cleanup_err), + ) + + # 3. 注意:绝不能把 str(e) 回给客户端 —— SQLAlchemy IntegrityError 的 str + # 会包含完整 SQL + 全部 parameters,参数里有 LiteLLM 明文 key + # (litellm_key_id 列)。日志里完整记录便于排障,响应只给通用错误码。 + logger.error("用户注册失败", email=req.email, exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"注册失败: {str(e)}" + detail={"code": "REGISTER_FAILED", + "message": "注册失败,请稍后重试或联系管理员"}, ) + + # DB commit 已成功 —— 最后才真正消费验证码。这一步即使失败也不再回滚 + # (用户已经注册成功,重复消费没意义;Redis 里的过期码不会被复用,因为 + # 同 email 第二次 register 会在 line 737 的 existing_user 检查处 400)。 + try: + await verify_code(req.email, req.verification_code) + except Exception as e: + logger.warning("注册成功后消费验证码失败(不影响注册结果)", + email=req.email, error=str(e)) # 创建JWT token,自动登录 token_data = { @@ -1112,8 +1188,117 @@ async def forgot_password_reset( await db.commit() logger.info("密码重置成功", email=req.email, user_id=str(user.id)) - + return SuccessResponse( message="密码重置成功,请使用新密码登录" ) + +# ============================================================ +# Heicode §7.8.1 — 内部端点:Heicode 后端 syncLocalUserFromAgnet 调用 +# 标记用户的 billing_provider('newapi' / 'litellm') +# ============================================================ + + +def _verify_internal_service_token(request: Request) -> None: + """校验内部服务密钥(不是用户 JWT)。失败 401/403/503。""" + expected = (settings.heicode_internal_service_token or "").strip() + if not expected: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "code": "INTERNAL_SERVICE_AUTH_NOT_CONFIGURED", + "message": "HEICODE_INTERNAL_SERVICE_TOKEN 未配置", + }, + ) + auth = (request.headers.get("Authorization") or "").strip() + if not auth.lower().startswith("bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authorization: Bearer required", + ) + presented = auth[7:].strip() + # 常量时间比较防 timing attack + if not secrets.compare_digest(presented, expected): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "INVALID_INTERNAL_SERVICE_TOKEN", + "message": "internal service token mismatch"}, + ) + + +class _BillingProviderUpdate(BaseModel): + """内部端点请求体。email 或 user_id 二选一。""" + email: Optional[str] = None + user_id: Optional[str] = None + billing_provider: str # 'newapi' | 'litellm' + + +@router.put("/internal/billing-provider", response_model=SuccessResponse) +async def set_billing_provider( + payload: _BillingProviderUpdate, + request: Request, + db: AsyncSession = Depends(get_db), +): + """Heicode §7.8.1 内部端点:标记用户的 billing_provider。 + + **不**用 user JWT 鉴权,用 HEICODE_INTERNAL_SERVICE_TOKEN 共享密钥(避免 + 给 Heicode 后端发用户 JWT)。 + + 用途:Heicode 后端 syncLocalUserFromAgnet 同步出新用户时,调本接口把 + User.billing_provider 设为 'newapi'。 + """ + _verify_internal_service_token(request) + + if payload.billing_provider not in ("newapi", "litellm"): + raise HTTPException( + status_code=400, + detail={"code": "INVALID_BILLING_PROVIDER", + "message": "billing_provider 必须是 'newapi' 或 'litellm'"}, + ) + if not payload.email and not payload.user_id: + raise HTTPException( + status_code=400, + detail={"code": "MISSING_USER_REF", + "message": "email 或 user_id 二选一必填"}, + ) + + user = None + if payload.user_id: + try: + uid = uuid.UUID(payload.user_id) + except Exception: + raise HTTPException(status_code=400, detail="user_id 不是有效 UUID") + user = await db.get(User, uid) + else: + result = await db.execute(select(User).where(User.email == payload.email)) + user = result.scalar_one_or_none() + + if user is None: + raise HTTPException( + status_code=404, + detail={"code": "USER_NOT_FOUND", + "message": f"找不到用户 (email={payload.email}, user_id={payload.user_id})"}, + ) + + old = user.billing_provider + user.billing_provider = payload.billing_provider + await db.commit() + await db.refresh(user) + + logger.info( + "billing_provider_updated", + user_id=str(user.id), + email=user.email, + old_provider=old, + new_provider=user.billing_provider, + actor="heicode_backend_internal", + ) + + return SuccessResponse(data={ + "user_id": str(user.id), + "email": user.email, + "billing_provider": user.billing_provider, + "old_billing_provider": old, + }) + diff --git a/services/mcp-server/app/routes/heicode_events.py b/services/mcp-server/app/routes/heicode_events.py new file mode 100644 index 0000000..cd67a9b --- /dev/null +++ b/services/mcp-server/app/routes/heicode_events.py @@ -0,0 +1,378 @@ +""" +Heicode 事件 + 高危审批(§7.8.3) + +3 个端点: + GET /api/user/events/stream SSE 单通道 + GET /api/user/approvals 待处理审批列表 + POST /api/user/approvals/{id}/decision 用户响应审批 + ++ 1 个内部端点(mcp-server 内部 / 测试用): + POST /api/auth/internal/approvals 创建审批 + 广播 approval.requested + +事件类型见 [Heicode-接口契约文档.md §7]: + approval.requested / approval.resolved / task.status_changed / + task.execution_progress / heartbeat +""" +from __future__ import annotations + +import asyncio +import json +import secrets as _secrets +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import settings +from database import get_db +from models import HeicodeApproval, User +from app.auth import require_auth +from app.event_bus import get_event_bus +from app.schemas import SuccessResponse + + +router = APIRouter(tags=["Heicode 事件 / 审批"]) + +HEARTBEAT_INTERVAL_SEC = 25 + + +# ==================== 共享工具 ==================== + +def _current_user_id(principal: dict) -> uuid.UUID: + uid = principal.get("user_id") or (principal.get("claims") or {}).get("sub") + if not uid: + raise HTTPException(status_code=401, detail="未登录") + try: + return uuid.UUID(str(uid)) + except Exception: + raise HTTPException(status_code=400, detail="user_id 不是有效 UUID") + + +def _approval_to_dict(a: HeicodeApproval) -> Dict[str, Any]: + return { + "approval_id": str(a.id), + "user_id": str(a.user_id), + "task_id": str(a.task_id) if a.task_id else None, + "task_name": a.task_name, + "operation": a.operation, + "target_resource": a.target_resource, + "requesting_role": a.requesting_role, + "risk_level": a.risk_level, + "impact_summary": a.impact_summary or [], + "heicode_suggestion": a.heicode_suggestion, + "derives_short_lived_credential": a.derives_short_lived_credential, + "ttl_minutes": a.ttl_minutes, + "enqueued_at": a.enqueued_at.isoformat() + "Z" if a.enqueued_at else None, + "decision": a.decision, + "resolved_by": str(a.resolved_by) if a.resolved_by else None, + "resolved_at": a.resolved_at.isoformat() + "Z" if a.resolved_at else None, + } + + +def _is_expired(a: HeicodeApproval) -> bool: + if a.decision is not None: + return False + if not a.enqueued_at or not a.ttl_minutes: + return False + # DB 字段是 timezone-aware(TIMESTAMP WITH TIME ZONE),需用 aware now + enq = a.enqueued_at + if enq.tzinfo is None: + enq = enq.replace(tzinfo=timezone.utc) + return datetime.now(timezone.utc) > enq + timedelta(minutes=a.ttl_minutes) + + +def _verify_internal_service_token(request: Request) -> None: + """与 auth.py 同款 — 用 HEICODE_INTERNAL_SERVICE_TOKEN 共享密钥校验。""" + expected = (settings.heicode_internal_service_token or "").strip() + if not expected: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={"code": "INTERNAL_SERVICE_AUTH_NOT_CONFIGURED", + "message": "HEICODE_INTERNAL_SERVICE_TOKEN 未配置"}, + ) + auth = (request.headers.get("Authorization") or "").strip() + if not auth.lower().startswith("bearer "): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Authorization: Bearer required", + ) + presented = auth[7:].strip() + if not _secrets.compare_digest(presented, expected): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"code": "INVALID_INTERNAL_SERVICE_TOKEN", + "message": "internal service token mismatch"}, + ) + + +# ==================== Pydantic 请求体 ==================== + +class ApprovalDecision(BaseModel): + decision: str # 'approve' | 'reject' + + +class ApprovalCreate(BaseModel): + """内部端点(service-token 鉴权)创建 approval 用""" + user_id: Optional[str] = None + user_email: Optional[str] = None + task_id: Optional[str] = None + task_name: str = Field(..., max_length=255) + operation: str + target_resource: str = Field(..., max_length=500) + requesting_role: str = Field(..., max_length=100) + risk_level: str = "high" + impact_summary: List[str] = Field(default_factory=list) + heicode_suggestion: Optional[str] = None + derives_short_lived_credential: bool = False + ttl_minutes: int = 60 + + +# ==================== SSE 端点 ==================== + +@router.get("/api/user/events/stream") +async def stream_events( + request: Request, + principal: dict = Depends(require_auth), +): + """SSE 单通道 — 推送当前用户的 approval / task 事件 + 25s heartbeat。 + + 客户端建议用 fetch + ReadableStream 实现(EventSource 不支持 Authorization 头)。 + """ + user_id = str(_current_user_id(principal)) + bus = get_event_bus() + queue = await bus.subscribe(user_id) + + async def gen(): + try: + # 立刻发一个 hello heartbeat 让客户端知道连接成功 + yield ( + "event: heartbeat\n" + f"data: {json.dumps({'server_time': datetime.utcnow().isoformat() + 'Z', 'connected': True})}\n\n" + ) + + while True: + # 客户端断连退出 + if await request.is_disconnected(): + break + # 等下一个事件 / heartbeat 周期 + try: + payload = await asyncio.wait_for( + queue.get(), timeout=HEARTBEAT_INTERVAL_SEC + ) + except asyncio.TimeoutError: + yield ( + "event: heartbeat\n" + f"data: {json.dumps({'server_time': datetime.utcnow().isoformat() + 'Z'})}\n\n" + ) + continue + + event_type = payload.get("event") or "message" + data = payload.get("data") or {} + yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + finally: + await bus.unsubscribe(user_id, queue) + + return StreamingResponse( + gen(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", # 反向代理关闭 buffering(如 Nginx) + "Connection": "keep-alive", + }, + ) + + +# ==================== 公共 REST:列待处理 + 决定 ==================== + +@router.get("/api/user/approvals", response_model=SuccessResponse) +async def list_my_approvals( + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + """当前用户**待处理**审批列表(启动时拉一次,之后靠 SSE 增量)。""" + user_id = _current_user_id(principal) + result = await db.execute( + select(HeicodeApproval) + .where(HeicodeApproval.user_id == user_id) + .where(HeicodeApproval.decision.is_(None)) + .order_by(HeicodeApproval.enqueued_at.desc()) + .limit(200) + ) + items = [] + expired_ids = [] + for a in result.scalars().all(): + if _is_expired(a): + expired_ids.append(a.id) + continue + items.append(_approval_to_dict(a)) + + # lazy expire:访问列表时把已过期的标记为 expired(不广播 resolved,避免噪声) + if expired_ids: + for aid in expired_ids: + a = await db.get(HeicodeApproval, aid) + if a and a.decision is None: + a.decision = "expired" + a.resolved_at = datetime.utcnow() + await db.commit() + + return SuccessResponse(data={"items": items, "total": len(items)}) + + +@router.post("/api/user/approvals/{approval_id}/decision", response_model=SuccessResponse) +async def decide_approval( + approval_id: str, + payload: ApprovalDecision, + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + """用户响应审批(approve / reject)。""" + user_id = _current_user_id(principal) + if payload.decision not in ("approve", "reject"): + raise HTTPException( + status_code=400, + detail={"code": "INVALID_DECISION", + "message": "decision 必须是 'approve' 或 'reject'"}, + ) + try: + aid = uuid.UUID(approval_id) + except Exception: + raise HTTPException(status_code=400, detail="approval_id 不是有效 UUID") + + a = await db.get(HeicodeApproval, aid) + if a is None: + raise HTTPException( + status_code=404, + detail={"code": "NOT_FOUND", "message": "审批不存在"}, + ) + if a.user_id != user_id: + raise HTTPException( + status_code=403, + detail={"code": "FORBIDDEN_SCOPE", + "message": "该审批不属于当前用户"}, + ) + if _is_expired(a): + # lazy 标记 expired + a.decision = "expired" + a.resolved_at = datetime.utcnow() + await db.commit() + raise HTTPException( + status_code=410, + detail={"code": "APPROVAL_EXPIRED", + "message": "审批已超时(已自动标记为 expired)"}, + ) + if a.decision is not None: + raise HTTPException( + status_code=409, + detail={"code": "ALREADY_RESOLVED", + "message": f"审批已被响应(decision={a.decision})"}, + ) + + a.decision = payload.decision + a.resolved_by = user_id + a.resolved_at = datetime.utcnow() + await db.commit() + await db.refresh(a) + + # SSE 广播 approval.resolved(让该用户其他设备的弹窗自动关闭) + await get_event_bus().emit( + str(user_id), + "approval.resolved", + { + "approval_id": str(a.id), + "decision": a.decision, + "resolved_by": str(a.resolved_by), + "resolved_at": a.resolved_at.isoformat() + "Z", + }, + ) + return SuccessResponse(data=_approval_to_dict(a)) + + +# ==================== 内部端点:创建 approval(service-token 鉴权)==================== + +@router.post("/api/auth/internal/approvals", response_model=SuccessResponse) +async def create_approval_internal( + payload: ApprovalCreate, + request: Request, + db: AsyncSession = Depends(get_db), +): + """内部端点(service-token 鉴权):创建一个待处理审批 + 广播 approval.requested。 + + 用途: + - 单元测试 / 联调 mock + - 未来 Agnet 真实链路中"高危操作触发审批"的入口 + """ + _verify_internal_service_token(request) + + if payload.risk_level not in ("low", "medium", "high"): + raise HTTPException( + status_code=400, + detail={"code": "INVALID_RISK_LEVEL", + "message": "risk_level 必须是 low/medium/high"}, + ) + if payload.heicode_suggestion not in (None, "approve", "reject", "delegate"): + raise HTTPException( + status_code=400, + detail={"code": "INVALID_SUGGESTION", + "message": "heicode_suggestion 必须是 approve/reject/delegate"}, + ) + if not (payload.user_id or payload.user_email): + raise HTTPException( + status_code=400, + detail={"code": "MISSING_USER_REF", + "message": "user_id 或 user_email 二选一必填"}, + ) + + # 解析 target user + user = None + if payload.user_id: + try: + uid = uuid.UUID(payload.user_id) + except Exception: + raise HTTPException(status_code=400, detail="user_id 不是有效 UUID") + user = await db.get(User, uid) + else: + result = await db.execute(select(User).where(User.email == payload.user_email)) + user = result.scalar_one_or_none() + if user is None: + raise HTTPException( + status_code=404, + detail={"code": "USER_NOT_FOUND", "message": "目标用户不存在"}, + ) + + task_uuid: Optional[uuid.UUID] = None + if payload.task_id: + try: + task_uuid = uuid.UUID(payload.task_id) + except Exception: + raise HTTPException(status_code=400, detail="task_id 不是有效 UUID") + + a = HeicodeApproval( + user_id=user.id, + task_id=task_uuid, + task_name=payload.task_name, + operation=payload.operation, + target_resource=payload.target_resource, + requesting_role=payload.requesting_role, + risk_level=payload.risk_level, + impact_summary=payload.impact_summary, + heicode_suggestion=payload.heicode_suggestion, + derives_short_lived_credential=payload.derives_short_lived_credential, + ttl_minutes=payload.ttl_minutes, + ) + db.add(a) + await db.commit() + await db.refresh(a) + + # 广播 approval.requested + await get_event_bus().emit( + str(user.id), + "approval.requested", + _approval_to_dict(a), + ) + return SuccessResponse(data=_approval_to_dict(a)) diff --git a/services/mcp-server/app/routes/heicode_proxy.py b/services/mcp-server/app/routes/heicode_proxy.py new file mode 100644 index 0000000..fbb55bb --- /dev/null +++ b/services/mcp-server/app/routes/heicode_proxy.py @@ -0,0 +1,222 @@ +""" +Heicode NewAPI 用户视角元数据透传层(P4) + +mcp-server 包装 4 个端点暴露给前端,背后调 Heicode NewAPI(admin token)。 +前端只对接 mcp-server 一个域,避免直连 NewAPI 引入的 CORS/审计/限流问题。 + +依据: +- Docs/Heicode-对接进度与待办.md §2.3.1(Heicode 团队 2026-05-07 决策 ④:透传方案) +- Docs/Heicode-完整调用流程图.md §2.5 + +端点: +- GET /api/user/heicode/balance → 当前用户余额 + group + status +- GET /api/user/heicode/models → 当前用户可用模型清单(按 group 过滤的视图) +- GET /api/user/heicode/usage → 30 天用量数据 +- GET /api/user/heicode/logs → 最近 50 条调用日志 +""" +from __future__ import annotations + +import uuid +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db +from models import User +from app.auth import require_auth +from app.heicode_client import ( + HeicodeNewAPIClient, HeicodeNewAPIError, get_heicode_client, +) +from app.schemas import SuccessResponse + + +router = APIRouter(prefix="/api/user/heicode", tags=["Heicode NewAPI 透传"]) + + +# ==================== 共享:取当前 mcp-server 用户的 email ==================== + +async def _current_user_email(principal: dict, db: AsyncSession) -> str: + """取当前登录用户的 email(用于解析 heicode 本地 user_id)""" + user_id = principal.get("user_id") or (principal.get("claims") or {}).get("sub") + if not user_id: + raise HTTPException(status_code=401, detail="未登录") + + # 优先 claims.email + email = (principal.get("claims") or {}).get("email") or principal.get("email") + if email: + return str(email).strip().lower() + + # fallback 查 DB + try: + uid = uuid.UUID(str(user_id)) + except Exception: + raise HTTPException(status_code=400, detail="user_id 不是有效 UUID") + + user = await db.get(User, uid) + if user is None or not user.email: + raise HTTPException(status_code=404, detail="当前用户无 email,无法关联到 Heicode 账号") + return user.email.strip().lower() + + +async def _resolve_heicode_uid( + client: HeicodeNewAPIClient, email: str, +) -> int: + """email → heicode user_id(int);找不到 502""" + if not client.is_configured(): + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail={ + "code": "HEICODE_NEWAPI_NOT_CONFIGURED", + "message": "Heicode NewAPI 未配置 admin token,请联系运维", + }, + ) + try: + uid = await client.resolve_user_id_by_email(email) + except HeicodeNewAPIError as e: + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail={ + "code": "HEICODE_NEWAPI_UPSTREAM_ERROR", + "message": str(e), + }, + ) + if uid is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={ + "code": "HEICODE_USER_NOT_FOUND", + "message": f"在 Heicode NewAPI 找不到 email='{email}' 的用户。" + f"用户需先在 Heicode 完成首次登录建账(from-agnet 流程)。", + }, + ) + return uid + + +def _request_id(request: Request) -> str: + return request.headers.get("X-Request-Id") or str(uuid.uuid4()) + + +# ==================== 4 个透传端点 ==================== + +@router.get("/balance", response_model=SuccessResponse) +async def get_user_balance( + request: Request, + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + """ + 返回当前用户在 Heicode NewAPI 上的:余额、group、status、剩余 quota 等关键字段。 + (NewAPI 的 quota 单位见其文档,通常是积分) + """ + email = await _current_user_email(principal, db) + client = get_heicode_client() + uid = await _resolve_heicode_uid(client, email) + try: + info = await client.get_user_info(uid, request_id=_request_id(request)) + except HeicodeNewAPIError as e: + raise HTTPException(status_code=502, detail={"code": "HEICODE_NEWAPI_UPSTREAM_ERROR", "message": str(e)}) + + # 提炼前端常用字段(不要直接 dump 上游全部字段,避免泄露多余信息) + info = info or {} + return SuccessResponse(data={ + "heicodeUserId": uid, + "email": email, + "username": info.get("username"), + "displayName": info.get("display_name"), + "group": info.get("group"), + "status": info.get("status"), + "quota": info.get("quota"), + "usedQuota": info.get("used_quota"), + "requestCount": info.get("request_count"), + }) + + +@router.get("/models", response_model=SuccessResponse) +async def get_user_models( + request: Request, + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + """ + 返回当前 Heicode NewAPI admin 视野内的全部模型清单。 + 前端结合 /balance 的 `group` 字段做用户可见性过滤。 + """ + email = await _current_user_email(principal, db) + client = get_heicode_client() + uid = await _resolve_heicode_uid(client, email) + try: + # 修订 2026-05-08(§7.11.2):改调 admin /api/user/{id}/models 而非 /api/models + models = await client.list_user_models(uid, request_id=_request_id(request)) + except HeicodeNewAPIError as e: + raise HTTPException(status_code=502, detail={"code": "HEICODE_NEWAPI_UPSTREAM_ERROR", "message": str(e)}) + + return SuccessResponse(data={ + "heicodeUserId": uid, + "email": email, + "items": models, + "count": len(models) if isinstance(models, list) else 0, + }) + + +@router.get("/usage", response_model=SuccessResponse) +async def get_user_usage( + request: Request, + days: int = Query(30, ge=1, le=90, description="拉取最近多少天的用量数据"), + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + """ + 返回当前用户在 Heicode NewAPI 上最近 N 天的用量数据(按日聚合)。 + 默认 30 天。 + """ + email = await _current_user_email(principal, db) + client = get_heicode_client() + uid = await _resolve_heicode_uid(client, email) + try: + items = await client.get_user_quota_dates( + uid, days=days, request_id=_request_id(request), + ) + except HeicodeNewAPIError as e: + raise HTTPException(status_code=502, detail={"code": "HEICODE_NEWAPI_UPSTREAM_ERROR", "message": str(e)}) + + return SuccessResponse(data={ + "heicodeUserId": uid, + "email": email, + "days": days, + "items": items, + "count": len(items) if isinstance(items, list) else 0, + }) + + +@router.get("/logs", response_model=SuccessResponse) +async def get_user_logs( + request: Request, + limit: int = Query(50, ge=1, le=200, description="返回的日志条数"), + page: int = Query(1, ge=1), + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + """ + 返回当前用户在 Heicode NewAPI 上最近 N 条调用日志。 + 默认最近 50 条。 + """ + email = await _current_user_email(principal, db) + client = get_heicode_client() + uid = await _resolve_heicode_uid(client, email) + try: + items = await client.get_user_logs( + uid, page=page, page_size=limit, request_id=_request_id(request), + ) + except HeicodeNewAPIError as e: + raise HTTPException(status_code=502, detail={"code": "HEICODE_NEWAPI_UPSTREAM_ERROR", "message": str(e)}) + + return SuccessResponse(data={ + "heicodeUserId": uid, + "email": email, + "page": page, + "limit": limit, + "items": items, + "count": len(items) if isinstance(items, list) else 0, + }) diff --git a/services/mcp-server/app/routes/heicode_tasks.py b/services/mcp-server/app/routes/heicode_tasks.py new file mode 100644 index 0000000..610f621 --- /dev/null +++ b/services/mcp-server/app/routes/heicode_tasks.py @@ -0,0 +1,705 @@ +""" +Heicode 任务编排 API(cc-haha 切片 7/8/9/10 任务驾驶舱配套) + +8 个端点: + POST /api/user/tasks/intent 创建任务(用户输入想法 → 第一轮 followups) + GET /api/user/tasks 用户任务列表 + GET /api/user/tasks/{id} 任务详情 + POST /api/user/tasks/{id}/answer 提交一个 followup 选项 → 触发下一轮 followups 或 TaskCard + POST /api/user/tasks/{id}/messages 用户继续追加要求 + GET /api/user/tasks/{id}/execution §7.8.2 执行反馈(Slice 8) + GET /api/user/tasks/{id}/delivery §7.8.2 交付结果(Slice 9) + GET /api/user/tasks/{id}/audit?tab=... §7.8.2 任务详情审计(Slice 10) + +字段形态严格对齐 cc-haha/desktop/src/stores/heicodeTaskStore.ts。 + +MVP 编排器(无 LLM 调用,确定性): + - intent → 2 个 followups(scope + tech) + - 全部 followups 答完 → 生成 TaskCard + - 后续 messages → 追加到 thread 不重新追问 + +后续可在 _orchestrator 模块加 LLM 增强(用 LiteLLM 生成更智能的 followups), +对前端 0 影响(字段形态完全一致)。 +""" +from __future__ import annotations + +import time +import uuid +from datetime import datetime +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from database import get_db +from models import HeicodeApproval, HeicodeTask, User +from app.auth import require_auth +from app.event_bus import get_event_bus +from app.schemas import SuccessResponse + + +router = APIRouter(prefix="/api/user/tasks", tags=["Heicode 任务编排"]) + + +# ==================== 数据模型(Pydantic) ==================== + +class IntentRequest(BaseModel): + intent: str = Field(..., min_length=2, max_length=4000) + name: Optional[str] = None # 可选,若不传从 intent 抽取 + + +class AnswerRequest(BaseModel): + question_id: str + option_id: str + + +class MessageRequest(BaseModel): + text: str = Field(..., min_length=1, max_length=4000) + + +# ==================== 编排器(确定性 MVP) ==================== + +# 初始 followups 模板 — cc-haha wireframe §3 风格 +INITIAL_FOLLOWUPS: List[Dict[str, Any]] = [ + { + "id": "scope", + "question": "你希望第一阶段交付到什么程度?", + "options": [ + {"id": "mvp", "label": "MVP:能跑通主流程"}, + {"id": "polish", "label": "完整功能 + UI 细节打磨"}, + {"id": "prod", "label": "直接上生产", "risk": "high-risk"}, + ], + }, + { + "id": "tech", + "question": "技术栈倾向?", + "options": [ + {"id": "modern_web", "label": "现代 Web(React + Node/Python)"}, + {"id": "py_backend", "label": "Python 后端为主"}, + {"id": "let_heicode", "label": "让 Heicode 决定"}, + ], + }, +] + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _new_question_id() -> str: + return f"q_{uuid.uuid4().hex[:8]}" + + +def _intent_to_name(intent: str) -> str: + """从 intent 抽取一个简短任务名(首句或前 30 字)""" + s = intent.strip().split("\n")[0].strip() + # 取到第一个标点符号 + for sep in ["。", "!", "?", ".", "!", "?", ",", ","]: + idx = s.find(sep) + if idx != -1 and idx > 5: + s = s[:idx] + break + if len(s) > 30: + s = s[:28] + "…" + return s or "新任务" + + +def _build_initial_thread(intent: str) -> List[Dict[str, Any]]: + """根据用户的 intent 构造初始 thread: + [user 消息, heicode 第一轮追问]""" + now = _now_ms() + return [ + {"kind": "user", "text": intent, "at": now}, + { + "kind": "heicode", + "text": "好的。我先问你两个问题,方便我组织接下来的工作。", + "at": now + 1, + "followups": INITIAL_FOLLOWUPS, + }, + ] + + +def _all_followups_answered(thread: List[Dict[str, Any]]) -> bool: + """检查 thread 中是否所有 followup 都已答完""" + for turn in thread: + if turn.get("kind") == "heicode": + for f in (turn.get("followups") or []): + if not f.get("answer"): + return False + # 至少有一个 heicode turn 才算 + return any(t.get("kind") == "heicode" and t.get("followups") for t in thread) + + +def _collect_answers(thread: List[Dict[str, Any]]) -> Dict[str, str]: + """从 thread 抽出 {question_id: option_id} 字典""" + out: Dict[str, str] = {} + for turn in thread: + for f in (turn.get("followups") or []): + if f.get("answer"): + out[f["id"]] = f["answer"] + return out + + +def _generate_task_card(intent: str, answers: Dict[str, str]) -> Dict[str, Any]: + """根据已收集的 answers + intent 生成 TaskCard。 + MVP:基于模板 + answers 简单填空。""" + scope_answer = answers.get("scope", "mvp") + tech_answer = answers.get("tech", "let_heicode") + + scope_map = { + "mvp": ["MVP 范围:核心功能跑通", "技术债务可后续清理", "暂不做高级 UI/性能优化"], + "polish": ["完整功能交付", "UI 细节打磨", "覆盖核心异常场景"], + "prod": ["直接生产部署(高风险)", "完整测试覆盖", "灰度 / 回滚预案", "生产监控接入"], + } + tech_map = { + "modern_web": "React + Node/Python 后端", + "py_backend": "Python 后端为主", + "let_heicode": "由 Heicode 团队按场景决定", + } + + return { + "goal": _intent_to_name(intent), + "scope": scope_map.get(scope_answer, scope_map["mvp"]), + "generated_artifacts": [ + "产品说明", + "原型描述", + "技术方案", + f"代码骨架({tech_map.get(tech_answer, '待定')})", + ], + "manager_actions": [ + { + "label": "去 Manager 准备资源", + "deeplink": "/manager/resources?from=task", + }, + { + "label": "查看团队建议", + "deeplink": "/manager/team?from=task", + }, + ], + } + + +# ==================== 共享工具 ==================== + +def _current_user_id(principal: dict) -> uuid.UUID: + uid = principal.get("user_id") or (principal.get("claims") or {}).get("sub") + if not uid: + raise HTTPException(status_code=401, detail="未登录") + try: + return uuid.UUID(str(uid)) + except Exception: + raise HTTPException(status_code=400, detail="user_id 格式无效") + + +def _to_dict(t: HeicodeTask) -> Dict[str, Any]: + return { + "id": str(t.id), + "user_id": str(t.user_id), + "name": t.name, + "status": t.status, + "status_caption": t.status_caption, + "intent": t.intent, + "thread": t.thread or [], + "card": t.card, + "created_at": int(t.created_at.timestamp() * 1000) if t.created_at else None, + "updated_at": int(t.updated_at.timestamp() * 1000) if t.updated_at else None, + } + + +async def _load_owned(db: AsyncSession, task_id: str, user_id: uuid.UUID) -> HeicodeTask: + try: + tid = uuid.UUID(task_id) + except Exception: + raise HTTPException(status_code=400, detail="task_id 格式无效") + task = await db.get(HeicodeTask, tid) + if task is None: + raise HTTPException(status_code=404, detail={"code": "NOT_FOUND", "message": "任务不存在"}) + if task.user_id != user_id: + raise HTTPException( + status_code=403, detail={"code": "FORBIDDEN_SCOPE", "message": "无权访问该任务"} + ) + return task + + +def _mark_dirty(task: HeicodeTask) -> None: + """SQLAlchemy 对 JSON 字段的修改不会自动检测;强制重新赋值确保持久化""" + task.thread = list(task.thread or []) + if task.card is not None: + task.card = dict(task.card) + + +# ==================== 1. POST /tasks/intent ==================== + +@router.post("/intent", response_model=SuccessResponse) +async def create_task_from_intent( + payload: IntentRequest, + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + """用户输入想法,创建新任务并返回第一轮 followups""" + user_id = _current_user_id(principal) + name = (payload.name or _intent_to_name(payload.intent))[:255] + + task = HeicodeTask( + user_id=user_id, + name=name, + status="configuring", + status_caption="等待你回答几个问题", + intent=payload.intent, + thread=_build_initial_thread(payload.intent), + card=None, + ) + db.add(task) + await db.commit() + await db.refresh(task) + return SuccessResponse(data=_to_dict(task)) + + +# ==================== 2. GET /tasks ==================== + +@router.get("", response_model=SuccessResponse) +async def list_tasks( + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), + status_filter: Optional[str] = Query(None, alias="status"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +): + user_id = _current_user_id(principal) + stmt = select(HeicodeTask).where(HeicodeTask.user_id == user_id) + if status_filter: + stmt = stmt.where(HeicodeTask.status == status_filter) + stmt = stmt.order_by(HeicodeTask.updated_at.desc()).offset(offset).limit(limit) + result = await db.execute(stmt) + items = [_to_dict(t) for t in result.scalars().all()] + return SuccessResponse(data={ + "items": items, "total": len(items), + "offset": offset, "limit": limit, + }) + + +# ==================== 3. GET /tasks/{id} ==================== + +@router.get("/{task_id}", response_model=SuccessResponse) +async def get_task( + task_id: str, + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + user_id = _current_user_id(principal) + task = await _load_owned(db, task_id, user_id) + return SuccessResponse(data=_to_dict(task)) + + +# ==================== 4. POST /tasks/{id}/answer ==================== + +@router.post("/{task_id}/answer", response_model=SuccessResponse) +async def answer_followup( + task_id: str, + payload: AnswerRequest, + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + """用户选了某个 followup 选项;如全部答完,生成 TaskCard 并把 status 推到 running""" + user_id = _current_user_id(principal) + task = await _load_owned(db, task_id, user_id) + if task.status not in ("draft", "configuring"): + raise HTTPException( + status_code=400, + detail={"code": "TASK_STATE_INVALID", + "message": f"任务当前状态 {task.status},不能再答 followup"}, + ) + + thread = list(task.thread or []) + matched = False + valid_option_ids: List[str] = [] + for turn in thread: + if turn.get("kind") != "heicode": + continue + for f in (turn.get("followups") or []): + if f.get("id") == payload.question_id: + valid_option_ids = [o["id"] for o in (f.get("options") or [])] + if payload.option_id not in valid_option_ids: + raise HTTPException( + status_code=400, + detail={ + "code": "INVALID_OPTION", + "message": f"option_id '{payload.option_id}' 不在该 followup 的允许选项内 {valid_option_ids}", + }, + ) + f["answer"] = payload.option_id + matched = True + break + if matched: + break + + if not matched: + raise HTTPException( + status_code=404, + detail={"code": "QUESTION_NOT_FOUND", + "message": f"找不到 question_id '{payload.question_id}'"}, + ) + + # 追加用户消息(让 thread 反映用户做了选择) + answered_label = next( + (o["label"] for f in (turn.get("followups") or []) + for o in (f.get("options") or []) + if f["id"] == payload.question_id and o["id"] == payload.option_id), + payload.option_id, + ) + thread.append({ + "kind": "user", + "text": f"我选了:{answered_label}", + "at": _now_ms(), + }) + + # 全部答完?生成 TaskCard + old_status = task.status + status_changed_to: Optional[str] = None + if _all_followups_answered(thread): + answers = _collect_answers(thread) + card = _generate_task_card(task.intent, answers) + thread.append({ + "kind": "heicode", + "text": "好,按你的选择,我整理出这张任务卡。你可以去 Manager 准备资源 / 查看团队建议。", + "at": _now_ms(), + }) + task.card = card + task.status = "running" + task.status_caption = "任务卡已生成,等待 Manager 准备资源" + status_changed_to = "running" + + task.thread = thread + task.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(task) + + # SSE 广播 task.status_changed(§7.8.3) + if status_changed_to and status_changed_to != old_status: + await get_event_bus().emit( + str(user_id), + "task.status_changed", + { + "task_id": str(task.id), + "old_status": old_status, + "new_status": status_changed_to, + "status_caption": task.status_caption, + "at": datetime.utcnow().isoformat() + "Z", + }, + ) + + return SuccessResponse(data=_to_dict(task)) + + +# ==================== 5. POST /tasks/{id}/messages ==================== + +@router.post("/{task_id}/messages", response_model=SuccessResponse) +async def post_message( + task_id: str, + payload: MessageRequest, + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + """用户继续追加要求;MVP 仅追加到 thread,不再生成新 followups""" + user_id = _current_user_id(principal) + task = await _load_owned(db, task_id, user_id) + thread = list(task.thread or []) + thread.append({ + "kind": "user", + "text": payload.text, + "at": _now_ms(), + }) + # 简单 ack + thread.append({ + "kind": "heicode", + "text": "收到。我会把这条要求纳入任务上下文。", + "at": _now_ms() + 1, + }) + task.thread = thread + task.updated_at = datetime.utcnow() + await db.commit() + await db.refresh(task) + return SuccessResponse(data=_to_dict(task)) + + +# ==================== 6. GET /tasks/{id}/execution (§7.8.2 Slice 8) ==================== + +def _iso_z(dt: Optional[datetime]) -> Optional[str]: + if dt is None: + return None + return dt.isoformat() + ("Z" if dt.tzinfo is None else "") + + +def _seed_execution(task: HeicodeTask) -> Dict[str, Any]: + """根据 task 状态 + intent,确定性生成 ExecutionState(mock 数据,字段对齐 cc-haha + heicodeTaskStore.ts: ExecutionState)。 + + 生命周期:task.status ∈ {running, awaiting_approval, completed} 才有意义; + 其他状态返回空 sub_steps。 + """ + base_at = int((task.created_at.timestamp() if task.created_at else time.time()) * 1000) + finished = task.status == "completed" + + # 5 个 sub_step(与 cc-haha seed 对齐) + steps = [ + ("step_1", "需求分解", "done"), + ("step_2", "技术方案设计", "done"), + ("step_3", "代码骨架生成", "done" if finished else "running"), + ("step_4", "测试 & 联调", "done" if finished else "waiting"), + ("step_5", "部署 & 验证", "done" if finished else "waiting"), + ] + sub_steps = [ + { + "id": sid, + "title": title, + "status": status_, + "caption": "已完成" if status_ == "done" else ( + "进行中" if status_ == "running" else "排队中" + ), + "at": base_at + i * 1000, + } + for i, (sid, title, status_) in enumerate(steps) + ] + + sk_tool_calls = [ + { + "id": "tc_1", + "name": "read_codebase", + "status": "done", + "summary": "扫描了 23 个文件,识别出主入口与核心依赖", + "at": base_at + 200, + }, + { + "id": "tc_2", + "name": "draft_spec", + "status": "done", + "summary": "草拟了 PRD(含 4 个 user story)", + "at": base_at + 1200, + }, + { + "id": "tc_3", + "name": "scaffold_repo", + "status": "done" if finished else "running", + "summary": "生成 React + FastAPI 骨架", + "at": base_at + 2200, + }, + ] + + events = [ + {"id": "ev_1", "level": "info", "message": "任务启动,分配资源池 default-pool", + "at": base_at + 100}, + {"id": "ev_2", "level": "info", "message": "需求分解完成,生成 5 个 sub_step", + "at": base_at + 1100}, + {"id": "ev_3", + "level": "warn" if not finished else "info", + "message": ("当前在 step_3:代码骨架生成中" if not finished else "已完成全部 sub_step"), + "at": base_at + 2100}, + ] + + artifacts = [ + {"id": "art_1", "kind": "doc", "label": "产品说明(PRD)", + "url": f"/manager/artifacts/{task.id}/prd.md"}, + {"id": "art_2", "kind": "doc", "label": "原型描述", + "url": f"/manager/artifacts/{task.id}/proto.md"}, + {"id": "art_3", "kind": "api", "label": "API 设计稿", + "url": f"/manager/artifacts/{task.id}/api.yaml"}, + {"id": "art_4", "kind": "diff", "label": "代码骨架 diff", + "url": f"/manager/artifacts/{task.id}/scaffold.diff"}, + ] + + return { + "sub_steps": sub_steps, + "sk_tool_calls": sk_tool_calls, + "events": events, + "artifacts": artifacts, + "spend_today": "¥12.30", + } + + +@router.get("/{task_id}/execution", response_model=SuccessResponse) +async def get_task_execution( + task_id: str, + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + """§7.8.2 Slice 8:执行反馈面板。任务在 running/awaiting_approval/completed + 时填充 sub_steps / sk_tool_calls / events / artifacts / spend_today。""" + user_id = _current_user_id(principal) + task = await _load_owned(db, task_id, user_id) + if task.status not in ("running", "awaiting_approval", "completed"): + # 未启动的任务返回空 ExecutionState(前端会显示「等待启动」占位) + return SuccessResponse(data={ + "sub_steps": [], "sk_tool_calls": [], "events": [], + "artifacts": [], "spend_today": None, + }) + return SuccessResponse(data=_seed_execution(task)) + + +# ==================== 7. GET /tasks/{id}/delivery (§7.8.2 Slice 9) ==================== + +def _seed_delivery(task: HeicodeTask) -> Dict[str, Any]: + """task.status == completed 时生成 DeliveryResult。""" + return { + "summary": f"任务「{task.name}」已交付。包含产品说明、代码骨架、测试报告 3 大类产物,可直接推进到部署阶段。", + "deliverables": [ + { + "id": "deliv_1", + "kind": "product-spec", + "title": "产品说明 PRD v1", + "primary_action": {"label": "查看文档", + "deeplink": f"/manager/artifacts/{task.id}/prd.md"}, + }, + { + "id": "deliv_2", + "kind": "code-diff", + "title": "代码骨架(React + FastAPI)", + "primary_action": {"label": "查看 diff", + "deeplink": f"/manager/artifacts/{task.id}/scaffold.diff"}, + "secondary_action": {"label": "去 Manager 拉分支", + "deeplink": f"/manager/git?task={task.id}"}, + }, + { + "id": "deliv_3", + "kind": "test-env", + "title": "测试环境 staging-001", + "primary_action": {"label": "打开测试环境", + "deeplink": f"/manager/envs/staging-001?from=task"}, + }, + { + "id": "deliv_4", + "kind": "prod-env", + "title": "生产部署预案", + "primary_action": {"label": "查看预案", + "deeplink": f"/manager/deploy/plan?task={task.id}"}, + }, + ], + "quality": [ + {"id": "q_1", "label": "单元测试覆盖率", "status": "pass", "detail": "82%"}, + {"id": "q_2", "label": "Lint / 静态扫描", "status": "pass", "detail": "0 个高危"}, + {"id": "q_3", "label": "安全扫描", "status": "pass", "detail": "通过"}, + {"id": "q_4", "label": "性能基线", "status": "warn", "detail": "p95 略高于阈值,建议复测"}, + ], + "next_actions": [ + {"label": "去 Manager 准备资源", "intent": "manager.resources"}, + {"label": "进入测试联调", "intent": "manager.test"}, + {"label": "提交灰度发布申请", "intent": "manager.deploy"}, + ], + } + + +@router.get("/{task_id}/delivery", response_model=SuccessResponse) +async def get_task_delivery( + task_id: str, + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), +): + """§7.8.2 Slice 9:交付结果面板。仅 task.status == completed 时返回完整数据; + 其他状态返回空 DeliveryResult(前端显示「任务尚未完成」占位)。""" + user_id = _current_user_id(principal) + task = await _load_owned(db, task_id, user_id) + if task.status != "completed": + return SuccessResponse(data={ + "summary": "", "deliverables": [], "quality": [], "next_actions": [], + }) + return SuccessResponse(data=_seed_delivery(task)) + + +# ==================== 8. GET /tasks/{id}/audit?tab=... (§7.8.2 Slice 10) ==================== + +_AUDIT_TABS = {"usage", "resources", "approvals", "security"} + + +def _audit_seed_usage(task: HeicodeTask) -> List[Dict[str, Any]]: + """模型用量(mock)""" + return [ + {"id": "u_1", "model": "gpt-4o-mini", "input_tokens": 12_400, + "output_tokens": 3_200, "cost": "¥4.20", "at_iso": _iso_z(task.created_at)}, + {"id": "u_2", "model": "claude-sonnet-4", "input_tokens": 8_100, + "output_tokens": 2_600, "cost": "¥6.80", "at_iso": _iso_z(task.created_at)}, + {"id": "u_3", "model": "gpt-4o", "input_tokens": 2_300, + "output_tokens": 1_100, "cost": "¥1.30", "at_iso": _iso_z(task.updated_at)}, + ] + + +def _audit_seed_resources(task: HeicodeTask) -> List[Dict[str, Any]]: + """资源访问(mock)""" + return [ + {"id": "r_1", "resource": "git/heicode-frontend", "scope": "read", + "last_used_iso": _iso_z(task.updated_at)}, + {"id": "r_2", "resource": "k8s/staging-001", "scope": "deploy", + "last_used_iso": _iso_z(task.updated_at)}, + {"id": "r_3", "resource": "secret/db-readonly", "scope": "read", + "last_used_iso": _iso_z(task.updated_at)}, + ] + + +async def _audit_real_approvals( + db: AsyncSession, task: HeicodeTask +) -> List[Dict[str, Any]]: + """approvals tab:从 heicode_approvals 真表拉对应 task 的审批历史""" + result = await db.execute( + select(HeicodeApproval) + .where(HeicodeApproval.task_id == task.id) + .order_by(HeicodeApproval.enqueued_at.desc()) + .limit(50) + ) + out: List[Dict[str, Any]] = [] + for a in result.scalars().all(): + out.append({ + "id": str(a.id), + "operation": a.operation, + "target_resource": a.target_resource, + "risk_level": a.risk_level, + "decision": a.decision, + "enqueued_iso": _iso_z(a.enqueued_at), + "resolved_iso": _iso_z(a.resolved_at), + }) + return out + + +def _audit_seed_security(task: HeicodeTask) -> List[Dict[str, Any]]: + """安全事件(mock)""" + return [ + {"id": "s_1", "event": "task_created", "level": "info", + "at_iso": _iso_z(task.created_at)}, + {"id": "s_2", "event": "resource_grant_used", "level": "info", + "at_iso": _iso_z(task.updated_at)}, + ] + + +@router.get("/{task_id}/audit", response_model=SuccessResponse) +async def get_task_audit( + task_id: str, + principal: dict = Depends(require_auth), + db: AsyncSession = Depends(get_db), + tab: Optional[str] = Query(None, description="usage|resources|approvals|security;不传则返回 4 tab 全量"), +): + """§7.8.2 Slice 10:任务详情抽屉。 + - 默认(不传 tab)返回 `{usage, resources, approvals, security}` 4 tab 全量; + - 传 tab 则只返回该 tab 字段;其他 tab 字段返回空数组(保持 schema 稳定)。 + + 其中 `approvals` 是真实查 heicode_approvals 表,其他 3 tab 是 deterministic mock。 + """ + user_id = _current_user_id(principal) + task = await _load_owned(db, task_id, user_id) + if tab is not None and tab not in _AUDIT_TABS: + raise HTTPException( + status_code=400, + detail={"code": "INVALID_TAB", + "message": f"tab 必须是 {sorted(_AUDIT_TABS)} 之一"}, + ) + + out: Dict[str, Any] = { + "usage": [], "resources": [], "approvals": [], "security": [], + } + if tab is None or tab == "usage": + out["usage"] = _audit_seed_usage(task) + if tab is None or tab == "resources": + out["resources"] = _audit_seed_resources(task) + if tab is None or tab == "approvals": + out["approvals"] = await _audit_real_approvals(db, task) + if tab is None or tab == "security": + out["security"] = _audit_seed_security(task) + return SuccessResponse(data=out) diff --git a/services/mcp-server/config.py b/services/mcp-server/config.py index 029aec9..16a123c 100644 --- a/services/mcp-server/config.py +++ b/services/mcp-server/config.py @@ -119,6 +119,23 @@ class Settings(BaseSettings): return "https://api-m.paypal.com" return "https://api-m.sandbox.paypal.com" + # Heicode NewAPI 集成(P4 — Manager 控制台展示模型/余额/用量元数据) + # 详见 Docs/Heicode-对接进度与待办.md §2.3 + heicode_newapi_base_url: str = os.getenv( + "HEICODE_NEWAPI_BASE_URL", "https://code.xinghanlab.com" + ) + # 由 Heicode 团队提供:mcp-server-service 用户的 admin access token + heicode_newapi_admin_token: str = os.getenv("HEICODE_NEWAPI_SERVICE_TOKEN", "") + # admin token 对应用户的 user_id(NewAPI UserAuth 强制 New-Api-User 头与 token 用户匹配) + heicode_newapi_admin_user_id: str = os.getenv("HEICODE_NEWAPI_ADMIN_USER_ID", "") + # 调用超时(秒) + heicode_newapi_timeout: int = int(os.getenv("HEICODE_NEWAPI_TIMEOUT", "10")) + + # Heicode 后端→mcp-server 的反向调用用的共享密钥(§7.8.1 内部 set-provider 端点) + # Heicode 后端在 syncLocalUserFromAgnet 时调 PUT /api/auth/internal/billing-provider + # 用 Authorization: Bearer <这个值> 鉴权 + heicode_internal_service_token: str = os.getenv("HEICODE_INTERNAL_SERVICE_TOKEN", "") + # 云存储设置(Azure Blob Storage) azure_storage_connection_string: str = os.getenv("AZURE_STORAGE_CONNECTION_STRING", "") s3_bucket: str = os.getenv("S3_BUCKET", "taiji-ai-exports") diff --git a/services/mcp-server/migrations/026_add_heicode_tasks.sql b/services/mcp-server/migrations/026_add_heicode_tasks.sql new file mode 100644 index 0000000..41d21d6 --- /dev/null +++ b/services/mcp-server/migrations/026_add_heicode_tasks.sql @@ -0,0 +1,23 @@ +-- Migration 026: Heicode 任务编排表 +-- 配套 cc-haha 切片 7 任务驾驶舱 + 工作台 UI(heicodeTaskStore 字段对齐) +-- 字段定义来自 cc-haha/desktop/src/stores/heicodeTaskStore.ts + +CREATE TABLE IF NOT EXISTS heicode_tasks ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + name VARCHAR(255) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'draft', + -- draft | configuring | running | awaiting_approval | completed | failed | paused + status_caption VARCHAR(255), + intent TEXT NOT NULL, + thread JSONB NOT NULL DEFAULT '[]'::jsonb, + -- ChatTurn[]:用户消息 + Heicode 追问 + 用户答案 + card JSONB, + -- TaskCard:goal/scope[]/generated_artifacts[]/manager_actions[] + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_heicode_tasks_user ON heicode_tasks(user_id); +CREATE INDEX IF NOT EXISTS idx_heicode_tasks_status ON heicode_tasks(status); +CREATE INDEX IF NOT EXISTS idx_heicode_tasks_updated ON heicode_tasks(updated_at DESC); diff --git a/services/mcp-server/migrations/027_add_user_billing_provider.sql b/services/mcp-server/migrations/027_add_user_billing_provider.sql new file mode 100644 index 0000000..c6336ec --- /dev/null +++ b/services/mcp-server/migrations/027_add_user_billing_provider.sql @@ -0,0 +1,17 @@ +-- Migration 027: Heicode §7.7.1 细节② — User.billing_provider 字段 +-- +-- 用途:mcp-server 创建子 Agent 部署时,根据用户的 billing_provider 决定 +-- POST /api/agnet/deployments 的 billing_context.provider 取值。 +-- +-- 默认 'litellm'(taijiagent 用户);Heicode 后端 syncLocalUserFromAgnet +-- 时通过 PUT /api/auth/internal/billing-provider 设为 'newapi'。 +-- +-- 完全增量:仅新增列,对现有业务零影响。 + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS billing_provider VARCHAR(20) NOT NULL DEFAULT 'litellm'; + +-- 允许值:'litellm' | 'newapi' +-- 不加 CHECK 约束(与现有 status / role 等字段保持风格一致,应用层校验) + +CREATE INDEX IF NOT EXISTS idx_users_billing_provider ON users(billing_provider); diff --git a/services/mcp-server/migrations/028_add_heicode_approvals.sql b/services/mcp-server/migrations/028_add_heicode_approvals.sql new file mode 100644 index 0000000..6e8cb19 --- /dev/null +++ b/services/mcp-server/migrations/028_add_heicode_approvals.sql @@ -0,0 +1,42 @@ +-- Migration 028: Heicode 高危审批表(§7.8.3 配套) +-- +-- 高危操作(risk_level=high)部署 / 资源访问产生 approval 记录;用户在 +-- cc-haha ApprovalDialog 选择 approve/reject 后落库;同时通过 SSE 单通道 +-- 把 approval.requested / approval.resolved 推给所有当前用户在线的客户端。 +-- +-- 字段对齐 cc-haha/desktop/src/stores/approvalStore.ts:ApprovalRequest + +CREATE TABLE IF NOT EXISTS heicode_approvals ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + -- 谁需要决策这个审批 + task_id UUID, + -- 关联任务(可空,如系统级审批);不加 FK 因为 task 可能由其他平台创建 + task_name VARCHAR(255) NOT NULL, + operation TEXT NOT NULL, + target_resource VARCHAR(500) NOT NULL, + requesting_role VARCHAR(100) NOT NULL, + risk_level VARCHAR(16) NOT NULL DEFAULT 'high', + -- low | medium | high + impact_summary JSONB NOT NULL DEFAULT '[]'::jsonb, + -- string[]:弹窗显示的 bullet 列表 + heicode_suggestion VARCHAR(16), + -- approve | reject | delegate + derives_short_lived_credential BOOLEAN NOT NULL DEFAULT false, + ttl_minutes INTEGER NOT NULL DEFAULT 60, + enqueued_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + decision VARCHAR(16), + -- approve | reject | expired(NULL = 待响应) + resolved_by UUID, + -- 谁做的决定(user_id);expired 时为 NULL + resolved_at TIMESTAMP WITH TIME ZONE, + -- BaseModel 框架字段(每张 ORM 表都需要) + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_heicode_approvals_user ON heicode_approvals(user_id); +CREATE INDEX IF NOT EXISTS idx_heicode_approvals_pending ON heicode_approvals(user_id, decision) + WHERE decision IS NULL; +CREATE INDEX IF NOT EXISTS idx_heicode_approvals_task ON heicode_approvals(task_id); +CREATE INDEX IF NOT EXISTS idx_heicode_approvals_enqueued ON heicode_approvals(enqueued_at); diff --git a/services/mcp-server/models.py b/services/mcp-server/models.py index 381bc74..cd8138c 100644 --- a/services/mcp-server/models.py +++ b/services/mcp-server/models.py @@ -91,9 +91,14 @@ class User(BaseModel, Base): rpm_limit = Column(Integer, default=60) # 每分钟请求数限制 tpm_limit = Column(Integer, default=10000) # 每分钟Token数限制 daily_cost_limit = Column(sa.Numeric(12, 2), default=100) # 每日成本限制 - + status = Column(String(20), default="active") - + + # Heicode §7.7.1 细节② — billing_provider + # 'litellm'(默认,taijiagent 用户)/ 'newapi'(Heicode 用户,由 from-agnet 同步设置) + # 决定 mcp-server 创建子 Agent 部署时 billing_context.provider 取值 + billing_provider = Column(String(20), nullable=False, default="litellm") + # 登录追踪 last_login_at = Column(DateTime, nullable=True) # 最后登录时间 @@ -1559,3 +1564,67 @@ class ResourceGrant(BaseModel, Base): Index("idx_resource_grants_role", role), ) + +# ============================================================ +# Heicode 任务编排(cc-haha 切片 7 UI 配套) +# ------------------------------------------------------------ +# 字段对齐 cc-haha/desktop/src/stores/heicodeTaskStore.ts +# ============================================================ + + +class HeicodeTask(BaseModel, Base): + """Heicode 任务(用户输入想法 → 追问 → 任务卡 → 部署)""" + __tablename__ = "heicode_tasks" + + user_id = Column(GUID(), ForeignKey("users.id"), nullable=False) + name = Column(String(255), nullable=False) + status = Column(String(32), nullable=False, default="draft") + # 允许值:draft | configuring | running | awaiting_approval | completed | failed | paused + status_caption = Column(String(255), nullable=True) + intent = Column(Text, nullable=False) + thread = Column(JSON, nullable=False, default=list) + # ChatTurn[]:用户消息 + Heicode 追问 + 用户答案 + card = Column(JSON, nullable=True) + # TaskCard:{ goal, scope[], generated_artifacts[], manager_actions[] } + + user = relationship("User", foreign_keys=[user_id]) + + __table_args__ = ( + Index("idx_heicode_tasks_user", user_id), + Index("idx_heicode_tasks_status", status), + ) + + +class HeicodeApproval(BaseModel, Base): + """Heicode 高危审批(§7.8.3 配套) + + 字段对齐 cc-haha/desktop/src/stores/approvalStore.ts:ApprovalRequest + """ + __tablename__ = "heicode_approvals" + + user_id = Column(GUID(), ForeignKey("users.id"), nullable=False) + task_id = Column(GUID(), nullable=True) # 不加 FK,task 可能由其他平台创建 + task_name = Column(String(255), nullable=False) + operation = Column(Text, nullable=False) + target_resource = Column(String(500), nullable=False) + requesting_role = Column(String(100), nullable=False) + risk_level = Column(String(16), nullable=False, default="high") + # 允许值:low | medium | high + impact_summary = Column(JSON, nullable=False, default=list) + heicode_suggestion = Column(String(16), nullable=True) + # 允许值:approve | reject | delegate + derives_short_lived_credential = Column(Boolean, nullable=False, default=False) + ttl_minutes = Column(Integer, nullable=False, default=60) + enqueued_at = Column(DateTime, nullable=False, default=datetime.utcnow) + decision = Column(String(16), nullable=True) + # 允许值:approve | reject | expired(NULL = 待响应) + resolved_by = Column(GUID(), nullable=True) + resolved_at = Column(DateTime, nullable=True) + + user = relationship("User", foreign_keys=[user_id]) + + __table_args__ = ( + Index("idx_heicode_approvals_user", user_id), + Index("idx_heicode_approvals_task", task_id), + ) +