Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6dbfc98552 |
@@ -1,53 +1,83 @@
|
||||
# AGENTS.md — Heicode Manager 单仓导航(给 Codex / 助手)
|
||||
# AGENTS.md — Heicode 单仓导航(给 Codex / 助手)
|
||||
|
||||
本文是 **仓库根级** 的快速地图与协作约定。细分栈规则看子目录自带文档(避免重复与漂移)。
|
||||
本文是 **仓库根级** 的快速地图与协作约定。细分栈的规则请看各子目录自带文档(避免重复与漂移)。
|
||||
|
||||
> **本仓 = Heicode Manager(HM)端。** 历史上客户端(`cc-haha`)、官网(`website`)与 Manager 曾在同一 monorepo;现已拆分,**本仓只保留 Heicode Manager**。客户端(终端 + 桌面)在 `heicode-macos-release-dev` / `heicode-winos-release-dev` 独立仓,不在本仓。
|
||||
|
||||
## 仓库地图
|
||||
## 仓库地图(explore 摘要)
|
||||
|
||||
| 路径 | 角色 | 栈 / 备注 |
|
||||
|------|------|-----------|
|
||||
| `heicode/` | **Heicode Manager**:基于 new-api 的模型网关 + 管理控制台 | Go(Gin/GORM)后端 + `web/default` 前端(Bun/Rsbuild/React) |
|
||||
| `docs/` | 产品共识 / 实施计划 / 集成契约 | Markdown,索引见 [`docs/README.md`](./docs/README.md) |
|
||||
| `cc-haha/` | **Heicode** 客户端:CLI(Ink)+ 本地 HTTP/WS 服务 + **Desktop**(Tauri + React) | Bun + TypeScript;产品入口 `bin/heicode` |
|
||||
| `heicode/` | **Heicode Manager**:网关 + 管理控制台 | Go(Gin/GORM)+ `web/default` 前端(Bun/Rsbuild/React) |
|
||||
| `website/` | 产品介绍站点 | Next.js;根 `docker-compose.yml` 提供 `heicode-www` :8888 |
|
||||
| `docs/` | 愿景、**里程碑**(`docs/milestones/`)、**Agent 集成**(`docs/integration/`) | Markdown |
|
||||
|
||||
根 `package.json` 仅少量 workspace 级依赖(如适配器用到的包);**主要开发依赖在 `cc-haha/package.json`**。
|
||||
|
||||
## 权威子文档(改代码前先打开对应一篇)
|
||||
|
||||
- **网关后端(`heicode/`,Go)**:[`heicode/AGENTS.md`](./heicode/AGENTS.md) — Go 分层、JSON / i18n / DB 规则、计费表达式等。
|
||||
- **产品共识与实施计划**:[`docs/heicode.md`](./docs/heicode.md)、[`docs/plan.md`](./docs/plan.md)。
|
||||
- **集成契约**:[`docs/integration/`](./docs/integration/) —— 当前模型「模板 Agent」见 `heicode-hm-template-agent-model.md`;HM↔AM 见 `heicode-am-contract.md`;桌面客户端对接见 `heicode-desktop-client-api.md`。
|
||||
- **客户端**:在 `heicode-macos-release-dev` / `heicode-winos-release-dev` 独立仓,不在本仓。
|
||||
- **客户端(cc-haha)**:[`cc-haha/AGENTS.md`](./cc-haha/AGENTS.md) — 目录结构、运行命令、测试、提交约定。
|
||||
- **网关(heicode)**:[`heicode/AGENTS.md`](./heicode/AGENTS.md) — Go 分层、JSON/i18n/DB 规则等。
|
||||
- **产品愿景与交付节奏**:[`docs/vision-heicode-full-stack-agentic-dev.md`](./docs/vision-heicode-full-stack-agentic-dev.md)、[`docs/milestones/README.md`](./docs/milestones/README.md)。
|
||||
|
||||
## 最小开发闭环
|
||||
|
||||
Heicode Manager = `heicode/`(Go 后端 + `web/default` 前端)。构建 / 运行命令与分层规则以 [`heicode/AGENTS.md`](./heicode/AGENTS.md) 与 `heicode/README.md` 为准。
|
||||
```bash
|
||||
# 依赖(客户端主体)
|
||||
cd cc-haha && bun install
|
||||
|
||||
## 关键触摸点(代码索引)
|
||||
# 终端 A:本地 API(桌面端依赖)
|
||||
bun run src/server/index.ts
|
||||
|
||||
- **客户端登录**:客户端走 HM 的 `/api/heicode-auth/*`(转发到登录服务)+ V2 设备签名;详见 `docs/integration/heicode-desktop-client-api.md`。
|
||||
- **Manager 侧 Heicode OAuth**:`heicode/controller/heicode_oauth.go`,路由 `heicode/router/heicode-router.go`(如 `/heicode/oauth/authorize`、`/heicode/oauth/session`)。
|
||||
- **HM↔AM(agent 部署)**:`heicode/controller/agent_template_runtime.go`、`agent_template_handlers.go`;契约见 `docs/integration/heicode-am-contract.md`。
|
||||
# 终端 B:桌面
|
||||
cd desktop && bun run tauri dev
|
||||
```
|
||||
|
||||
联调本机 Manager(heicode)时常见:
|
||||
|
||||
```bash
|
||||
HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 bun run src/server/index.ts
|
||||
```
|
||||
|
||||
更多变量见 `cc-haha/src/server/config/providerPresets.ts` 与 `cc-haha/src/server/api/heicode-auth.ts`(如 `HEICODE_<PROVIDER>_OAUTH_*`)。
|
||||
|
||||
## Heicode ↔ Manager 关键触摸点(代码索引)
|
||||
|
||||
- **客户端登录 / Provider**:`cc-haha/src/server/api/heicode-auth.ts`,路由前缀 `/api/heicode-auth/*`(由 `cc-haha/src/server/router.ts` 挂载)。
|
||||
- **模型发现**:`cc-haha/src/server/services/providerService.ts`、`cc-haha/src/server/api/providers.ts`。
|
||||
- **Provider 预设**:`cc-haha/src/server/config/providerPresets.json` + `providerPresets.ts`。
|
||||
- **Manager 侧 Heicode OAuth**:`heicode/controller/heicode_oauth.go`,路由 `heicode/router/heicode-router.go`(例如 `/heicode/oauth/authorize`、`/heicode/oauth/session`)。
|
||||
|
||||
## 协作约定(根级)
|
||||
|
||||
1. **改哪一层跟哪篇文档**:Go 行为以 `heicode/AGENTS.md` 为准;产品 / 架构以 `docs/heicode.md` + `docs/plan.md` 为准。
|
||||
2. **小步提交**:沿用历史风格(`feat:` / `fix:` / `docs:`);PR 写清影响面与验证步骤。
|
||||
3. **不要臆测计费**:计费在平台侧(new-api 计量),按 `docs/` 契约对接,不在本仓臆造扣费逻辑。
|
||||
1. **改哪一层跟哪篇文档**:Go 行为以 `heicode/AGENTS.md` 为准;客户端 TS/React 以 `cc-haha/AGENTS.md` 为准。
|
||||
2. **小步提交**:沿用历史风格(如 `feat:` / `fix:` / `docs:`);PR 写清影响面与验证步骤。
|
||||
3. **集成与「SK」边界**:平台契约见 [`docs/integration/agent-platform-api-design.md`](./docs/integration/agent-platform-api-design.md),里程碑见 `docs/milestones/`。
|
||||
4. **不要臆测计费**:计费与订阅在平台侧,不在 Heicode 客户端内实现。
|
||||
|
||||
## 当前线上入口
|
||||
## Docker / 站点
|
||||
|
||||
- 根目录 [`docker-compose.yml`](./docker-compose.yml):构建 `website/` 静态站点镜像(端口 **8888**)。
|
||||
- Manager 本地编排以 `heicode/docker-compose.yml` 及仓库内 override 为准(若存在)。
|
||||
- **VM / 生产同步代码**:优先在目标机上 **`git clone` / `git pull`** 与本仓库远程一致;**不要**用 `scp` 传整份源码或 compose(仅在仓库不可用或紧急热修时例外)。
|
||||
- **VM 部署后清镜像**:每次 `docker compose ... up -d --build` 后执行 **`sudo docker image prune -f`**,清掉重建产生的悬空层;定期可 **`sudo docker system prune -f`**(不删仍在使用的卷)。避免只叠新镜像、旧层占满磁盘。
|
||||
|
||||
## 当前线上入口(2026-04)
|
||||
|
||||
- **Heicode 官网(Azure Static Web Apps)**:`https://ashy-dune-0e22d7b00.7.azurestaticapps.net`
|
||||
- **Heicode Manager(生产)**:`https://code.xinghanlab.com/`
|
||||
|
||||
官网中的主按钮(登录/开始使用/CTA)默认应跳转到 `https://code.xinghanlab.com/`,避免出现历史 IP 地址。
|
||||
|
||||
## Manager 生产拓扑(Azure)
|
||||
|
||||
- 应用层:Azure VM 上运行 `heicode`(Docker 容器承载应用进程)。
|
||||
- 数据库与缓存:Azure 托管服务(不在 VM 上跑 PostgreSQL / Redis 容器)
|
||||
- PostgreSQL:`heicode.postgres.database.azure.com` / DB `heicode`
|
||||
- Redis:`heicode.redis.cache.windows.net:6380`(TLS)
|
||||
- 容器落盘:容器内产生的文件(`/data` 上传与持久文件、`/app/logs`)通过 **bind mount** 写到 VM 本地磁盘。编排见 `heicode/docker-compose.azure-vm.yml`,目录由 `HEICODE_DATA_ROOT` 指定。
|
||||
- **VM 部署后清镜像**:每次 `docker compose ... up -d --build` 后执行 `sudo docker image prune -f`;定期 `sudo docker system prune -f`(不删在用卷),避免旧层占满磁盘。
|
||||
- **VM / 生产同步代码**:优先在目标机 `git clone` / `git pull` 与远程一致;不要用 `scp` 传整份源码(紧急热修例外)。
|
||||
- 应用层:Azure VM 上运行 `heicode`(当前用 Docker 容器承载应用进程)。
|
||||
- 数据库与缓存:使用 Azure 托管服务(不在 VM 上跑 PostgreSQL/Redis 容器)
|
||||
- PostgreSQL: `heicode.postgres.database.azure.com` / DB `heicode`
|
||||
- Redis: `heicode.redis.cache.windows.net:6380`(TLS)
|
||||
- 容器落盘:VM 仅承载应用进程;**容器内产生的文件**(如 `/data` 上传与持久文件、`/app/logs`)通过 **bind mount** 写到 VM 本地磁盘,便于备份与重建容器后不丢。编排见 `heicode/docker-compose.azure-vm.yml`,目录由环境变量 `HEICODE_DATA_ROOT` 指定。
|
||||
- 说明:不要在生产 VM 上再启本地 `postgres`/`redis` 容器并指向业务库,避免与托管实例混淆。
|
||||
- 镜像清理:发版构建后删除悬空/旧镜像层(见上文「VM 部署后清镜像」),防止 OS 盘被 Docker 占满。
|
||||
|
||||
---
|
||||
|
||||
*若本文件与 `heicode/AGENTS.md` 或 `docs/` 冲突,以子目录 / `docs` 为准并及时更新本文件。*
|
||||
*若本文件与子目录 `AGENTS.md` / `AGENTS.md` 冲突,以子目录为准并及时更新根文件摘要。*
|
||||
|
||||
@@ -1,53 +1,83 @@
|
||||
# CLAUDE.md — Heicode Manager 单仓导航(给 Claude Code / 助手)
|
||||
# CLAUDE.md — Heicode 单仓导航(给 Claude Code / 助手)
|
||||
|
||||
本文是 **仓库根级** 的快速地图与协作约定。细分栈规则看子目录自带文档(避免重复与漂移)。
|
||||
本文是 **仓库根级** 的快速地图与协作约定。细分栈的规则请看各子目录自带文档(避免重复与漂移)。
|
||||
|
||||
> **本仓 = Heicode Manager(HM)端。** 历史上客户端(`cc-haha`)、官网(`website`)与 Manager 曾在同一 monorepo;现已拆分,**本仓只保留 Heicode Manager**。客户端(终端 + 桌面)在 `heicode-macos-release-dev` / `heicode-winos-release-dev` 独立仓,不在本仓。
|
||||
|
||||
## 仓库地图
|
||||
## 仓库地图(explore 摘要)
|
||||
|
||||
| 路径 | 角色 | 栈 / 备注 |
|
||||
|------|------|-----------|
|
||||
| `heicode/` | **Heicode Manager**:基于 new-api 的模型网关 + 管理控制台 | Go(Gin/GORM)后端 + `web/default` 前端(Bun/Rsbuild/React) |
|
||||
| `docs/` | 产品共识 / 实施计划 / 集成契约 | Markdown,索引见 [`docs/README.md`](./docs/README.md) |
|
||||
| `cc-haha/` | **Heicode** 客户端:CLI(Ink)+ 本地 HTTP/WS 服务 + **Desktop**(Tauri + React) | Bun + TypeScript;产品入口 `bin/heicode` |
|
||||
| `heicode/` | **Heicode Manager**:网关 + 管理控制台 | Go(Gin/GORM)+ `web/default` 前端(Bun/Rsbuild/React) |
|
||||
| `website/` | 产品介绍站点 | Next.js;根 `docker-compose.yml` 提供 `heicode-www` :8888 |
|
||||
| `docs/` | 愿景、**里程碑**(`docs/milestones/`)、**Agent 集成**(`docs/integration/`) | Markdown |
|
||||
|
||||
根 `package.json` 仅少量 workspace 级依赖(如适配器用到的包);**主要开发依赖在 `cc-haha/package.json`**。
|
||||
|
||||
## 权威子文档(改代码前先打开对应一篇)
|
||||
|
||||
- **网关后端(`heicode/`,Go)**:[`heicode/CLAUDE.md`](./heicode/CLAUDE.md) — Go 分层、JSON / i18n / DB 规则、计费表达式等。
|
||||
- **产品共识与实施计划**:[`docs/heicode.md`](./docs/heicode.md)、[`docs/plan.md`](./docs/plan.md)。
|
||||
- **集成契约**:[`docs/integration/`](./docs/integration/) —— 当前模型「模板 Agent」见 `heicode-hm-template-agent-model.md`;HM↔AM 见 `heicode-am-contract.md`;桌面客户端对接见 `heicode-desktop-client-api.md`。
|
||||
- **客户端**:在 `heicode-macos-release-dev` / `heicode-winos-release-dev` 独立仓,不在本仓。
|
||||
- **客户端(cc-haha)**:[`cc-haha/AGENTS.md`](./cc-haha/AGENTS.md) — 目录结构、运行命令、测试、提交约定。
|
||||
- **网关(heicode)**:[`heicode/CLAUDE.md`](./heicode/CLAUDE.md) — Go 分层、JSON/i18n/DB 规则等。
|
||||
- **产品愿景与交付节奏**:[`docs/vision-heicode-full-stack-agentic-dev.md`](./docs/vision-heicode-full-stack-agentic-dev.md)、[`docs/milestones/README.md`](./docs/milestones/README.md)。
|
||||
|
||||
## 最小开发闭环
|
||||
|
||||
Heicode Manager = `heicode/`(Go 后端 + `web/default` 前端)。构建 / 运行命令与分层规则以 [`heicode/CLAUDE.md`](./heicode/CLAUDE.md) 与 `heicode/README.md` 为准。
|
||||
```bash
|
||||
# 依赖(客户端主体)
|
||||
cd cc-haha && bun install
|
||||
|
||||
## 关键触摸点(代码索引)
|
||||
# 终端 A:本地 API(桌面端依赖)
|
||||
bun run src/server/index.ts
|
||||
|
||||
- **客户端登录**:客户端走 HM 的 `/api/heicode-auth/*`(转发到登录服务)+ V2 设备签名;详见 `docs/integration/heicode-desktop-client-api.md`。
|
||||
- **Manager 侧 Heicode OAuth**:`heicode/controller/heicode_oauth.go`,路由 `heicode/router/heicode-router.go`(如 `/heicode/oauth/authorize`、`/heicode/oauth/session`)。
|
||||
- **HM↔AM(agent 部署)**:`heicode/controller/agent_template_runtime.go`、`agent_template_handlers.go`;契约见 `docs/integration/heicode-am-contract.md`。
|
||||
# 终端 B:桌面
|
||||
cd desktop && bun run tauri dev
|
||||
```
|
||||
|
||||
联调本机 Manager(heicode)时常见:
|
||||
|
||||
```bash
|
||||
HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 bun run src/server/index.ts
|
||||
```
|
||||
|
||||
更多变量见 `cc-haha/src/server/config/providerPresets.ts` 与 `cc-haha/src/server/api/heicode-auth.ts`(如 `HEICODE_<PROVIDER>_OAUTH_*`)。
|
||||
|
||||
## Heicode ↔ Manager 关键触摸点(代码索引)
|
||||
|
||||
- **客户端登录 / Provider**:`cc-haha/src/server/api/heicode-auth.ts`,路由前缀 `/api/heicode-auth/*`(由 `cc-haha/src/server/router.ts` 挂载)。
|
||||
- **模型发现**:`cc-haha/src/server/services/providerService.ts`、`cc-haha/src/server/api/providers.ts`。
|
||||
- **Provider 预设**:`cc-haha/src/server/config/providerPresets.json` + `providerPresets.ts`。
|
||||
- **Manager 侧 Heicode OAuth**:`heicode/controller/heicode_oauth.go`,路由 `heicode/router/heicode-router.go`(例如 `/heicode/oauth/authorize`、`/heicode/oauth/session`)。
|
||||
|
||||
## 协作约定(根级)
|
||||
|
||||
1. **改哪一层跟哪篇文档**:Go 行为以 `heicode/CLAUDE.md` 为准;产品 / 架构以 `docs/heicode.md` + `docs/plan.md` 为准。
|
||||
2. **小步提交**:沿用历史风格(`feat:` / `fix:` / `docs:`);PR 写清影响面与验证步骤。
|
||||
3. **不要臆测计费**:计费在平台侧(new-api 计量),按 `docs/` 契约对接,不在本仓臆造扣费逻辑。
|
||||
1. **改哪一层跟哪篇文档**:Go 行为以 `heicode/CLAUDE.md` 为准;客户端 TS/React 以 `cc-haha/AGENTS.md` 为准。
|
||||
2. **小步提交**:沿用历史风格(如 `feat:` / `fix:` / `docs:`);PR 写清影响面与验证步骤。
|
||||
3. **集成与「SK」边界**:平台契约见 [`docs/integration/agent-platform-api-design.md`](./docs/integration/agent-platform-api-design.md),里程碑见 `docs/milestones/`。
|
||||
4. **不要臆测计费**:计费与订阅在平台侧,不在 Heicode 客户端内实现。
|
||||
|
||||
## 当前线上入口
|
||||
## Docker / 站点
|
||||
|
||||
- 根目录 [`docker-compose.yml`](./docker-compose.yml):构建 `website/` 静态站点镜像(端口 **8888**)。
|
||||
- Manager 本地编排以 `heicode/docker-compose.yml` 及仓库内 override 为准(若存在)。
|
||||
- **VM / 生产同步代码**:优先在目标机上 **`git clone` / `git pull`** 与本仓库远程一致;**不要**用 `scp` 传整份源码或 compose(仅在仓库不可用或紧急热修时例外)。
|
||||
- **VM 部署后清镜像**:每次 `docker compose ... up -d --build` 后执行 **`sudo docker image prune -f`**,清掉重建产生的悬空层;定期可 **`sudo docker system prune -f`**(不删仍在使用的卷)。避免只叠新镜像、旧层占满磁盘。
|
||||
|
||||
## 当前线上入口(2026-04)
|
||||
|
||||
- **Heicode 官网(Azure Static Web Apps)**:`https://ashy-dune-0e22d7b00.7.azurestaticapps.net`
|
||||
- **Heicode Manager(生产)**:`https://code.xinghanlab.com/`
|
||||
|
||||
官网中的主按钮(登录/开始使用/CTA)默认应跳转到 `https://code.xinghanlab.com/`,避免出现历史 IP 地址。
|
||||
|
||||
## Manager 生产拓扑(Azure)
|
||||
|
||||
- 应用层:Azure VM 上运行 `heicode`(Docker 容器承载应用进程)。
|
||||
- 数据库与缓存:Azure 托管服务(不在 VM 上跑 PostgreSQL / Redis 容器)
|
||||
- PostgreSQL:`heicode.postgres.database.azure.com` / DB `heicode`
|
||||
- Redis:`heicode.redis.cache.windows.net:6380`(TLS)
|
||||
- 容器落盘:容器内产生的文件(`/data` 上传与持久文件、`/app/logs`)通过 **bind mount** 写到 VM 本地磁盘。编排见 `heicode/docker-compose.azure-vm.yml`,目录由 `HEICODE_DATA_ROOT` 指定。
|
||||
- **VM 部署后清镜像**:每次 `docker compose ... up -d --build` 后执行 `sudo docker image prune -f`;定期 `sudo docker system prune -f`(不删在用卷),避免旧层占满磁盘。
|
||||
- **VM / 生产同步代码**:优先在目标机 `git clone` / `git pull` 与远程一致;不要用 `scp` 传整份源码(紧急热修例外)。
|
||||
- 应用层:Azure VM 上运行 `heicode`(当前用 Docker 容器承载应用进程)。
|
||||
- 数据库与缓存:使用 Azure 托管服务(不在 VM 上跑 PostgreSQL/Redis 容器)
|
||||
- PostgreSQL: `heicode.postgres.database.azure.com` / DB `heicode`
|
||||
- Redis: `heicode.redis.cache.windows.net:6380`(TLS)
|
||||
- 容器落盘:VM 仅承载应用进程;**容器内产生的文件**(如 `/data` 上传与持久文件、`/app/logs`)通过 **bind mount** 写到 VM 本地磁盘,便于备份与重建容器后不丢。编排见 `heicode/docker-compose.azure-vm.yml`,目录由环境变量 `HEICODE_DATA_ROOT` 指定。
|
||||
- 说明:不要在生产 VM 上再启本地 `postgres`/`redis` 容器并指向业务库,避免与托管实例混淆。
|
||||
- 镜像清理:发版构建后删除悬空/旧镜像层(见上文「VM 部署后清镜像」),防止 OS 盘被 Docker 占满。
|
||||
|
||||
---
|
||||
|
||||
*若本文件与 `heicode/CLAUDE.md` 或 `docs/` 冲突,以子目录 / `docs` 为准并及时更新本文件。*
|
||||
*若本文件与子目录 `AGENTS.md` / `CLAUDE.md` 冲突,以子目录为准并及时更新根文件摘要。*
|
||||
|
||||
@@ -180,8 +180,6 @@ Key Vault 最低权限:
|
||||
|
||||
## 7. 蜂群 Runtime 配置
|
||||
|
||||
> ⚠️ **2026-06-10 勘误(模型已更新)**:本节描述的「普通 sub + 蜂群两套模式 + `SWARM_RUNTIME_*` + `/api/swarms`」是**旧的「HM 主导编排」模型,已作废**。当前权威模型:HM **不实现 swarm runtime**;单 Agent 走「模板 Agent + AM」(见 `docs/integration/heicode-am-contract.md`),多 Agent 蜂群归 **`agent_swarm`**(产品名 HeiCode Swarm)仓,其编排为 Master-Agent(分解→派发→评审→汇总),契约见 `agent_swarm/docs/integration/runtime-contract.md`(待冻结,`agent_swarm#2`)。详见 `docs/integration/heicode-swarm-deferred.md`。下表 `SWARM_RUNTIME_*` 仅为**仍存在于 env 但当前关闭(`SWARM_RUNTIME_ENABLED=false`)**的历史开关,保留作记录,不代表当前接入形态。
|
||||
|
||||
蜂群模式和普通 sub 模式是两套部署、两套语义。Manager 当前环境里蜂群 Runtime 开关是关闭状态。
|
||||
|
||||
| 环境变量 | 当前状态 | 当前值 / 位置 | 说明 |
|
||||
@@ -198,7 +196,7 @@ Key Vault 最低权限:
|
||||
|
||||
| 项目 | 地址 | 说明 |
|
||||
|------|------|------|
|
||||
| `agent_swarm`(HeiCode Swarm)Orchestrator | `http://52.139.240.116:8000` | 蜂群项目独立 Runtime / Orchestrator(HM 侧 deferred,未在 Manager 生产 env 启用) |
|
||||
| HeiCode-Swarm Orchestrator | `http://52.139.240.116:8000` | 蜂群项目独立 Runtime / Orchestrator |
|
||||
|
||||
## 8. NewAPI / 模型网关配置
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
# Heicode Manager 项目说明与踩坑交接
|
||||
|
||||
更新时间:2026-06-01
|
||||
用途:给下一位 AI / 工程师快速理解 Heicode Manager 的项目边界、生产拓扑、普通 sub、蜂群、NewAPI 和已踩过的坑。
|
||||
|
||||
## 1. 一句话说明
|
||||
|
||||
Heicode Manager 是 Heicode 的服务端控制面和网页管理台,负责用户登录、模型网关配置、用户/设备/模型/渠道管理、普通 sub 敏捷任务控制、Runtime callback 落库、artifact/timeline 展示,以及和桌面客户端、Agent Manager Runtime、NewAPI、Azure Key Vault 之间的编排。
|
||||
|
||||
它不是桌面客户端本体,也不是 Agent Runtime 本体,也不是 NewAPI 本体。
|
||||
|
||||
## 2. 仓库结构
|
||||
|
||||
| 路径 | 作用 | 说明 |
|
||||
|------|------|------|
|
||||
| `heicode/` | Manager 后端和默认前端 | Go + Gin/GORM,`web/default` 是当前网页端 |
|
||||
| `cc-haha/` | Heicode 桌面客户端和本地服务 | Tauri + React + Bun,客户端到 Manager 的请求 body 会走加密/签名流程 |
|
||||
| `docs/` | 项目文档、部署、集成、进度清单 | 后续交接优先看这里 |
|
||||
| `docs/deployment/` | 生产部署和迁移文档 | VM、Azure、配置交接 |
|
||||
| `docs/integration/` | Runtime、桌面客户端、蜂群、普通 sub 对接文档 | 联调时优先看 |
|
||||
|
||||
开发前先看:
|
||||
|
||||
| 范围 | 文档 |
|
||||
|------|------|
|
||||
| 根仓库规则 | `AGENTS.md` |
|
||||
| Manager 规则 | `heicode/AGENTS.md` |
|
||||
| 生产配置 | `docs/deployment/Heicode-Manager-生产配置与账号交接清单.md` |
|
||||
| 普通 sub 桌面客户端对接 | `docs/integration/heicode-desktop-sub-agile-api.md` |
|
||||
| 普通 sub Agent Manager 对接 | `docs/integration/普通sub敏捷模式-AgentManager对接任务清单.md` |
|
||||
| 蜂群 Agent Manager 对接 | `docs/integration/蜂群模式-AgentManager对接任务清单.md` |
|
||||
|
||||
## 3. 生产拓扑
|
||||
|
||||
```text
|
||||
用户/桌面客户端
|
||||
-> https://code.xinghanlab.com
|
||||
-> Heicode Manager Docker container on Azure VM
|
||||
-> Azure PostgreSQL / Azure Redis
|
||||
-> NewAPI model gateway
|
||||
-> 普通 sub Agent Manager Runtime: http://20.212.121.126
|
||||
-> 蜂群 Runtime / Orchestrator: http://52.139.240.116:8000
|
||||
-> Azure Key Vault: https://heicode-kv.vault.azure.net
|
||||
```
|
||||
|
||||
当前 Manager 部署在 Azure VM 上,容器名 `heicode`,端口 `3000:3000`。VM 上也能看到 `new-api`、`postgres`、`redis`、`heicode-openbao` 容器,但正式业务数据库和缓存应以 Azure 托管 PostgreSQL / Redis 配置为准,不要误用 VM 本地容器判断生产数据。
|
||||
|
||||
## 4. 普通 sub 和蜂群必须分开
|
||||
|
||||
这是最容易踩坑的点。
|
||||
|
||||
| 模式 | 含义 | Manager 当前配置 | Runtime |
|
||||
|------|------|------------------|---------|
|
||||
| 普通 sub 敏捷模式 | 桌面客户端把一个开发任务拆给若干子 Agent,按需求、设计、开发、测试、部署等阶段推进 | `AGENT_RUNTIME_ENABLED=true` | `http://20.212.121.126` |
|
||||
| 蜂群模式 | HeiCode-Swarm 的多 Agent swarm run / task graph 模式 | `SWARM_RUNTIME_ENABLED=false` | `http://52.139.240.116:8000`,需单独启用和联调 |
|
||||
|
||||
不要因为两个接口都可能叫 `/api/swarms` 就把它们混成一个概念。普通 sub 是 Heicode 的任务组织方式;蜂群是独立 swarm runtime 形态。
|
||||
|
||||
## 5. 普通 sub 当前主流程
|
||||
|
||||
```text
|
||||
桌面客户端创建/补充任务
|
||||
-> Manager 用户态接口生成 deployment draft
|
||||
-> Manager 调 Agent Manager Runtime POST /api/swarms
|
||||
-> Runtime 创建 run 并启动子 Agent
|
||||
-> Runtime 执行中 callback Manager
|
||||
-> Manager 保存 timeline / events / logs / artifacts / usage
|
||||
-> 桌面客户端从 Manager 查询展示
|
||||
```
|
||||
|
||||
关键点:
|
||||
|
||||
1. Runtime 执行过程会回调状态、日志、timeline、用量。
|
||||
2. 最终业务交付物不是直接塞在聊天文本里,而是通过 `artifact.created` 落库。
|
||||
3. 客户端应先查 artifact 列表,再通过 content 接口下载完整产物。
|
||||
4. 如果 `usage=0` 或只有失败摘要 artifact,不能算真实业务交付完成。
|
||||
|
||||
## 6. artifact 展示坑
|
||||
|
||||
桌面客户端曾出现“以下是作为 Frontend 角色...”这种内容,看起来像交付物,其实多半只是 Runtime 的摘要文本。
|
||||
|
||||
正常设计应该是:
|
||||
|
||||
| 层级 | 应展示什么 |
|
||||
|------|------------|
|
||||
| 聊天时间线 | 阶段进度、Agent 状态、摘要说明 |
|
||||
| 交付产物卡片 | `artifact_id`、标题、类型、摘要、大小、hash、下载入口 |
|
||||
| 完整代码/文件 | 通过 `GET /api/agent/user/deployments/{deployment_id}/artifacts/{artifact_id}/content` 下载 |
|
||||
|
||||
如果 artifact 里有 `azblob://...` 或 `runtime://...`,客户端不应该直接暴露云凭据或要求用户自己访问 Blob,而应通过 Manager / Runtime content 代理接口拿完整内容。
|
||||
|
||||
## 7. NewAPI / 模型调用边界
|
||||
|
||||
NewAPI 是模型网关和用量计费入口,不是 Heicode Manager 自己的模型执行器。
|
||||
|
||||
排查模型问题时要区分三条链路:
|
||||
|
||||
| 链路 | 调用方 | 常见问题 |
|
||||
|------|--------|----------|
|
||||
| 桌面普通聊天 | 桌面客户端 -> Manager/NewAPI | 用户 token、模型列表、渠道权限、body 加密 |
|
||||
| 普通 sub Runtime Agent | Runtime 子 Agent -> NewAPI/Manager 模型网关 | Runtime 环境变量、模型名、base_url、请求路径、上游超时 |
|
||||
| Manager 后台模型配置 | 管理员网页 -> Manager/NewAPI | 渠道配置、分组、可用模型、价格表达式 |
|
||||
|
||||
曾踩过的坑:
|
||||
|
||||
- 某个模型 503/504 时,不一定是客户端参数错,也可能是 Runtime 子 Agent 使用的模型、base_url 或请求格式不对。
|
||||
- Runtime 需要回传 `newapi_request_id` 和非零 token usage,方便定位 NewAPI 日志。
|
||||
- 一个模型失败时可以切换模型验证,但不能把失败摘要 artifact 当成业务完成。
|
||||
|
||||
## 8. Azure Key Vault / OpenBao 边界
|
||||
|
||||
当前正式方向是 Azure Key Vault,不是 OpenBao。
|
||||
|
||||
| 项目 | 结论 |
|
||||
|------|------|
|
||||
| Azure Key Vault | 正式长期密钥托管方案 |
|
||||
| OpenBao | VM 上存在历史/兼容容器,不作为当前正式方案 |
|
||||
| Managed Identity | Manager 访问 Key Vault 的推荐方式 |
|
||||
| 当前已知问题 | 之前健康检查出现过 `Identity not found`,说明 VM 身份或 `AZURE_CLIENT_ID`/Vault 权限未配好 |
|
||||
|
||||
如果后续迁移到 Container Apps / AKS / App Service,不能只切域名。必须重新配置 Managed Identity、Key Vault 权限、环境变量、持久化、数据库/Redis 网络、Runtime callback 地址。
|
||||
|
||||
## 9. 登录、设备、模型列表问题排查
|
||||
|
||||
之前遇到过用户登录成功但设备看不到、模型列表拿不到的问题。排查顺序:
|
||||
|
||||
1. Manager 是否有该用户记录。
|
||||
2. JWT/session 是否能通过 Manager 校验。
|
||||
3. 设备注册/心跳是否入库。
|
||||
4. 用户是否绑定 NewAPI channel/group/token。
|
||||
5. NewAPI 返回是否 401/403/模型列表为空。
|
||||
6. Mac/Windows 客户端请求是否走同一 base_url、同一加密/签名逻辑。
|
||||
|
||||
不要只看“客户端显示已登录”,已登录不代表模型、设备、NewAPI 绑定都完整。
|
||||
|
||||
## 10. 网页端 Manager 已做过的重点
|
||||
|
||||
已处理过的方向包括:
|
||||
|
||||
- 注册页国内邮箱提示。
|
||||
- Manager 登录后菜单跳转问题。
|
||||
- 任务总览 / deployments 页面部分英文文案中文化。
|
||||
- 普通 sub 控制面、部署草稿、运行状态、timeline、events、logs、artifacts 展示。
|
||||
- artifact content 获取链路文档。
|
||||
- Azure Key Vault secret_ref 接入方向。
|
||||
- PayPal 充值和 NewAPI 模型费用关系说明文档。
|
||||
|
||||
继续改网页端时必须真实点击验证,尤其是:
|
||||
|
||||
- 登录后左侧菜单。
|
||||
- 模型、渠道、供应商、支付、部署、任务总览。
|
||||
- 创建新运行弹窗/抽屉。
|
||||
- 产物卡片和下载入口。
|
||||
|
||||
## 11. 部署和 git 规则
|
||||
|
||||
生产部署原则:
|
||||
|
||||
```text
|
||||
本地修改
|
||||
-> git commit
|
||||
-> git push 到 heicode-mananger main
|
||||
-> VM 上 git pull
|
||||
-> docker compose ... up -d --build
|
||||
-> sudo docker image prune -f
|
||||
-> 真实接口/页面冒烟
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- 不要用 `scp` 传整份源码到 VM。
|
||||
- `HeiCode-issues.git` 已废弃,后续不用再更新。
|
||||
- VM 构建后必须清理 Docker 镜像,避免磁盘被旧层占满。
|
||||
- 不要提交 `.env`、密钥、token、数据库连接串。
|
||||
|
||||
## 12. 新 AI 接手建议顺序
|
||||
|
||||
1. 读 `AGENTS.md` 和 `heicode/AGENTS.md`。
|
||||
2. 读 `docs/deployment/Heicode-Manager-生产配置与账号交接清单.md`。
|
||||
3. 用 `git status` 确认是否有未提交变更,不要动无关文件。
|
||||
4. 区分当前任务是普通 sub、蜂群、网页端、NewAPI、Azure 还是桌面客户端。
|
||||
5. 先用接口确认真实状态,再下结论。
|
||||
6. 涉及生产前先确认是否需要部署,部署后必须真实冒烟。
|
||||
7. 涉及密码、token、连接串时只写配置名和获取位置,不写明文。
|
||||
|
||||
## 13. 已踩过的典型坑
|
||||
|
||||
| 坑 | 正确处理 |
|
||||
|----|----------|
|
||||
| 把普通 sub 和蜂群混在一起 | 两套模式、两套配置、两套联调清单 |
|
||||
| artifact 摘要当完整交付 | 必须通过 content 接口拿完整产物 |
|
||||
| Runtime 返回 completed 就算成功 | 还要看 usage、artifact、日志、是否失败摘要 |
|
||||
| Key Vault health 报错只改代码 | 先查 Managed Identity 和 Vault 权限 |
|
||||
| NewAPI 一个模型失败就判 Manager 错 | 查 Runtime 请求路径、模型名、request id、上游状态 |
|
||||
| VM 上有 postgres/redis 容器就当生产库 | 以 `SQL_DSN`、`REDIS_CONN_STRING` 和 Azure 托管服务为准 |
|
||||
| 修改网页后不点击验证 | 必须真实打开页面、点菜单、点按钮 |
|
||||
| 把密钥写入 md 方便交接 | 只能写配置名、用途、位置,不能写明文 |
|
||||
|
||||
## 14. 当前后续重点
|
||||
|
||||
| 方向 | 后续任务 |
|
||||
|------|----------|
|
||||
| 普通 sub | 持续和桌面客户端联调真实开发任务,确认 artifact content 是完整业务产物 |
|
||||
| 蜂群 | 如需启用,先配置 `SWARM_RUNTIME_*`,单独跑蜂群 E2E |
|
||||
| Key Vault | 修复 Managed Identity / `AZURE_CLIENT_ID` / Key Vault 权限 |
|
||||
| NewAPI | 保持 Runtime 回传 `newapi_request_id`、usage、成本信息 |
|
||||
| 网页端 | 继续中文化、交互完善、真实点击测试 |
|
||||
| 生产部署 | 每次部署后检查 `/api/status`、登录、deployments、Runtime health |
|
||||
@@ -2,40 +2,74 @@
|
||||
|
||||
Heicode 面向**多人协作、可追溯交付**的软件团队:把需求对齐、实现、验证、发布和持续运营连成一条少断层的链路,并用**可编排的智能体角色**承接其中可标准化的环节——强调「能复盘、能审计、能按团队规模裁剪」,而不是罗列某一家的工具栈。
|
||||
|
||||
下面的目录表仅供工程查阅;**不代表对外产品承诺、路线图或你必须采用的集成方式。**
|
||||
|
||||
> **本仓 = Heicode Manager(HM)端。** 历史上终端/桌面客户端(`cc-haha`)、官网(`website`)与 Manager 曾同处一个 monorepo;现已拆分,**本仓只保留 Heicode Manager**(基于 new-api 的模型网关 + 管理控制台)。客户端(终端 + 桌面)在 `heicode-macos-release-dev` / `heicode-winos-release-dev` 独立仓。
|
||||
下面的目录表仅供工程查阅;**不代表对外产品承诺、路线图或你必须采用的集成方式。 **
|
||||
|
||||
## 这个仓库里有什么(工程布局)
|
||||
|
||||
| 目录 | 大致含义 |
|
||||
|------|----------|
|
||||
| `heicode/` | **Heicode Manager**:基于 new-api 的模型网关 + 管理控制台。Go(Gin/GORM)后端 + `web/default` 前端(Bun/Rsbuild/React)。 |
|
||||
| `docs/` | 产品共识、实施计划与集成契约(索引见 [`docs/README.md`](./docs/README.md))。 |
|
||||
|
||||
> 客户端与官网不在本仓。
|
||||
| `cc-haha/` | **Heicode**(终端与桌面客户端及本地服务;此为源码目录名)。 |
|
||||
| `heicode/` | **Heicode Manager**(网关与管理控制台服务端;此为源码目录名)。 |
|
||||
| `website/` | 产品介绍站点(Next.js;可 `pnpm dev` 或 Docker 预览)。 |
|
||||
| `docs/` | 愿景与范式;**[`docs/milestones/`](./docs/milestones/README.md)** 交付里程碑;**[`docs/integration/`](./docs/integration/README.md)** Agent 等平台接口设计。 |
|
||||
|
||||
## 产品在解决什么问题
|
||||
|
||||
- **协作范式**:把瀑布 / 敏捷下的角色分工落到可复述的闸门与智能体承接边界。
|
||||
- **协作范式**:把瀑布 / 敏捷下的角色分工落到可复述的闸门与智能体承接边界(**方向与原则**见 `docs/vision-heicode-full-stack-agentic-dev.md`;编队明细在文档附录)。
|
||||
- **交付可追溯**:文档、沟通与变更尽量与版本、发布对齐,便于复盘与合规。
|
||||
- **模型与执行分层**:HM 提供模型网关与权限 / 计费;云端 agent 由 agent_management(AM)部署,客户端拿到 agent 公网地址后**直连 agent 使用**。
|
||||
- **工程上**:同一仓库便于客户端与服务端**同步发版、统一回归**,减少「谁和谁版本对不上」的摩擦。
|
||||
|
||||
产品定位与架构共识见 [`docs/heicode.md`](./docs/heicode.md),实施计划见 [`docs/plan.md`](./docs/plan.md)。
|
||||
## 详细愿景与范式
|
||||
|
||||
**[docs/vision-heicode-full-stack-agentic-dev.md](./docs/vision-heicode-full-stack-agentic-dev.md)**
|
||||
|
||||
## 快速启动(开发联调)
|
||||
|
||||
Heicode Manager 在 `heicode/` 子目录:Go 后端 + `web/default` 前端。完整构建 / 运行命令与分层规则见 [`heicode/CLAUDE.md`](./heicode/CLAUDE.md) 与 `heicode/README.md`。
|
||||
```bash
|
||||
# 根依赖(Bun monorepo 根目录)
|
||||
bun install
|
||||
|
||||
与 Heicode 登录相关的最小路由(以实际代码为准):
|
||||
# Heicode 客户端本地服务(目录 cc-haha)
|
||||
cd cc-haha
|
||||
bun run src/server/index.ts
|
||||
|
||||
# 另开终端:桌面端
|
||||
cd cc-haha/desktop
|
||||
bun run tauri dev
|
||||
```
|
||||
|
||||
联调网关时示例:
|
||||
|
||||
```bash
|
||||
HEICODE_TAIJIAICLOUD_BASE_URL=http://localhost:3000 bun run src/server/index.ts
|
||||
```
|
||||
|
||||
**Heicode Manager**(`heicode/`)中与 Heicode 登录相关的路由(最小集,以实际代码为准):
|
||||
|
||||
- `GET /heicode/oauth/authorize`
|
||||
- `GET /heicode/oauth/session`
|
||||
|
||||
平台侧能力还包括模型发现(如 `GET /v1/models`)与 Anthropic Messages 兼容入口等。
|
||||
平台侧能力还包括模型发现(如 `GET /v1/models`)与 Anthropic Messages 兼容入口等,详见 **Heicode Manager**(`heicode/`)与 **Heicode 客户端**(`cc-haha/`)各自 README。
|
||||
|
||||
集成与对接契约见 [`docs/integration/`](./docs/integration/)(HM↔AM、桌面客户端对接等)。
|
||||
## 官网(Next.js)
|
||||
|
||||
```bash
|
||||
cd website && pnpm install && pnpm dev
|
||||
```
|
||||
|
||||
若根目录提供 `docker compose`,可按 compose 说明构建预览镜像(以仓库内 `docker-compose.yml` 为准)。
|
||||
|
||||
## 发版与回归建议
|
||||
|
||||
- 同一版本标签发布客户端与网关镜像。
|
||||
- 每次发版至少回归:**登录**、**模型拉取**、**对话请求**。
|
||||
- 先在本地 Docker / 本地联调通过,再做外网域名与证书。
|
||||
|
||||
## 相关外部参考(概念)
|
||||
|
||||
- [oh-my-claudecode](https://ohmyclaudecode.com/) — Claude Code 类工具的高效实践参考。
|
||||
- Agent 平台以实际部署环境与文档为准。
|
||||
|
||||
## 许可证
|
||||
|
||||
`heicode/`(Heicode Manager,基于 new-api)许可证见 `heicode/LICENSE`(常见为 AGPLv3)。
|
||||
各子项目许可证见各子目录内 `LICENSE`(例如 Heicode Manager / `heicode/` 侧常见为 AGPLv3)。
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
# Heicode Manager 统一改造落地计划(Manager 侧)
|
||||
|
||||
> 版本:v0.1(初稿)
|
||||
> 日期:2026-06-01
|
||||
> 负责范围:仅 `heicode-mananger`(Manager 控制面 + `web/default` 前端)
|
||||
> 性质:内部落地计划,随实现推进持续更新。不代表对外承诺。
|
||||
|
||||
> **状态更新(2026-06-01):`agnet → agent` 已按"强制全量、不留兼容"完成。**
|
||||
> 后端 Go(路由 `/api/agent/*`、env `AGENT_*`、结构体/函数/文件名)、前端(`agent-console`/`agent-hub`、`/api/agent` 调用、i18n)、DB(表 `agent_*`、列 `agent_id`,并加幂等迁移 `renameAgnetTablesToAgent` 保住老数据)、文档、compose/.env、脚本均已统一为 `agent`。
|
||||
> `go build ./...` 与 `go test ./controller ./model ./router ./middleware` 全绿;前端 `tsc -b` 通过。
|
||||
> 本文 §3 原描述的"保留 `agnet` 兼容别名/线缆令牌"策略已**作废**——实际采用一次性硬切。唯一保留 `agnet` 字样的是 `model/main.go` 的迁移源表名(迁移所必需)。
|
||||
> **部署待办**:① VM `.env` 的 `AGNET_*` 改名 `AGENT_*`;② 启动时迁移自动 rename 老表(已就绪);③ 其他三仓库(agent_management / HeiCode-Swarm / 客户端)需同步切到 `/api/agent` 与新 env,否则跨服务对接在它们切换前会中断(按你的要求强制先行)。
|
||||
|
||||
## 0. 基准文档
|
||||
|
||||
本计划是以下两份的 **Manager 侧拆解执行版**,结论以原文为准:
|
||||
|
||||
| 来源 | 位置 | 作用 |
|
||||
|---|---|---|
|
||||
| 统一调用方案 v0.1 | gitee `taijibaga/heicodedebug` → `2026-06-01_heicode客户端相关定义.md` | 四仓库总纲:接口归口、模式拆分、状态裁决、产物、回调 |
|
||||
| 全链路代码评审报告 | gitee `taijibaga/heicodedebug` → `2026-06-01_Heicode全链路代码评审报告.md` | P1–P7 问题与证据 |
|
||||
| 桌面 sub 对接文档 | `docs/integration/heicode-desktop-sub-agile-api.md` | 现行普通 sub 契约 |
|
||||
|
||||
四仓库分工(总纲 §4):客户端只调 Manager;**Manager 是唯一接口入口 + Runtime 路由器 + 唯一状态裁判**;`agent_management` 执行 Sub Agile;`HeiCode-Swarm` 执行 Swarm。
|
||||
|
||||
## 1. 贯穿全局的原则(来自总纲)
|
||||
|
||||
1. `agnet` 是历史拼写错误,统一为 `agent`;旧接口兼容期只做转发。
|
||||
2. Sub Agile 与 Swarm 是两套运行时,**不再复用 `/api/swarms` 表达两种模式**,按 `mode` 路由。
|
||||
3. Runtime 只上报**结构化事实**;Manager 据结构化字段裁决 `display_status`;客户端只消费结论,**不再用正则自判产物**。
|
||||
4. 安全红线:只传 `azkv://` 等 `secret_ref`,禁明文密钥;高危操作 Manager 先审批再下发。
|
||||
|
||||
## 2. 已完成(分支 `fix/manager-deliverable-and-secret-validation`,commit `12602eb`,已推送)
|
||||
|
||||
| 项 | 文件 | 内容 | 对应总纲 |
|
||||
|---|---|---|---|
|
||||
| P2 交付物判定 | `controller/agnet_runtime_client.go` | `runtimeArtifactsAreSummaryOnly` 改读结构化 `artifact_type`+文件信号,弃脆弱正则 | §10.6 |
|
||||
| P6a secret_ref 校验 | `controller/resource.go` | Resource CRUD 强制 `azkv://` | §16/§17.6 |
|
||||
| P6b 值级密钥扫描 | `controller/resource.go`、`controller/agnet_control_plane.go` | `containsPlaintextSecret`/`containsSensitiveGrantField` 扫字符串值(sk-/JWT/PEM) | §16 |
|
||||
| P5 默认模型收敛 | `controller/agnet_role_template.go`、`controller/agnet_task_bridge.go` | 单一来源 `defaultAgnetModelID()`(env `AGNET_DEFAULT_MODEL_ID`,默认 `gpt-5.4`);移除 `agnet-model-<role>` 占位回退 | §9 |
|
||||
| P3 状态枚举 | `docs/integration/heicode-desktop-sub-agile-api.md` | 补 `completed` 终态 + runtime_state 镜像 + 未知值兜底 | §10.4 |
|
||||
| 单测 | `controller/agnet_deliverable_secret_test.go` | 覆盖以上行为 | — |
|
||||
|
||||
> 注:`defaultAgnetModelID` 在后续命名统一中应改名 `defaultAgentModelID`,env 改 `AGENT_DEFAULT_MODEL_ID`(保留 `AGNET_*` 回退)。
|
||||
|
||||
## 3. 命名统一 `agnet → agent`
|
||||
|
||||
### 3.1 爆炸半径(仅本仓库,不含另三仓库与跨团队契约)
|
||||
|
||||
**2155 处 / 63 文件**(`rg -i agnet`)。按「客户端/其他服务是否依赖」分类:
|
||||
|
||||
| 类别 | 规模 | 对外可见 | 处理策略 | 风险 |
|
||||
|---|---|---|---|---|
|
||||
| HTTP 路由 `/api/agnet/*` | 路由表 | ✅ 客户端+Runtime | 新增 `/api/agent/*` 别名,旧保留转发 | 🔴 |
|
||||
| JSON 字段 | 仅 `agnet_id`(3 处,含 DB 列) | ✅ resource grant 契约 | 响应 dual-emit `agent_id`,请求 dual-accept | 🟠 |
|
||||
| env 变量 `AGNET_*` | compose + 代码读取 | ✅ 部署配置 | 代码先读 `AGENT_*` 回退 `AGNET_*` | 🟠 |
|
||||
| DB 表 `agnet_*` / `agnet_id` 列 | ~6 表 | ❌ 内部 | **用 GORM `TableName()` 钉住物理名不动**,物理改名作为最后单独迁移 | 🔴 |
|
||||
| Go 内部标识(`AgnetXxx`、函数、文件名 `agnet_*.go`) | ~1800 | ❌ 编译期可查 | 纯重构,分模块小步改 | 🟢 |
|
||||
| 前端(`agnet-console/`、组件、i18n key) | ~250 | ❌ 内部(调的是路由) | 重构,随路由切换 | 🟢 |
|
||||
| 文档 | 多处 | — | 新文档写 `agent`,`agnet` 标 deprecated | 🟢 |
|
||||
|
||||
### 3.2 兼容期规则(总纲 §19)
|
||||
|
||||
1. 旧接口只做转发。2. 新文档只写新接口。3. 新客户端只调新接口。4. Manager 内部存储统一 `agent` 命名。5. 日志可记 legacy route,不展示给普通用户。
|
||||
|
||||
## 4. 路由拆分与归口(总纲 §5)
|
||||
|
||||
### 4.1 客户端 → Manager(新增)
|
||||
```
|
||||
/api/heicode/capabilities
|
||||
/api/heicode/sub-agile/tasks/... (或统一 /api/heicode/tasks + body.mode)
|
||||
/api/heicode/swarm/tasks/...
|
||||
```
|
||||
|
||||
### 4.2 Manager → Runtime(新增,按 mode 分流)
|
||||
```
|
||||
/api/agent/sub-agile/deployments -> agent_management (AGENT_RUNTIME_* / 现 AGNET_RUNTIME_*)
|
||||
/api/agent/swarm/deployments -> HeiCode-Swarm (SWARM_RUNTIME_*)
|
||||
```
|
||||
Manager 已具备双 env 前缀分流机制(`agnetRuntimeClientConfigForMode`);蜂群侧配置就绪只差 `SWARM_RUNTIME_SERVICE_TOKEN`。
|
||||
|
||||
### 4.3 回调(新增 + 旧转发)
|
||||
```
|
||||
新:POST /api/agent/callbacks/runtime-events
|
||||
旧:POST /api/agnet/callbacks/swarm-events (兼容转发到同一 handler)
|
||||
```
|
||||
|
||||
## 5. 状态裁决:Manager 成为唯一裁判(总纲 §10,关联评审 P1)
|
||||
|
||||
### 5.1 三层状态 + display_status
|
||||
- `client_task_status`(客户端本地)/ `cloud_deployment_status`(Manager)/ `runtime_execution_status`(Runtime)
|
||||
- Manager 输出唯一 `display_status` 给客户端。
|
||||
|
||||
### 5.2 Runtime 必须上报的结构化交付物事实(契约)
|
||||
```json
|
||||
"deliverable": {
|
||||
"has_deliverable": true,
|
||||
"summary_only": false,
|
||||
"artifact_ids": ["art_xxx"],
|
||||
"files_modified": ["src/app.ts"],
|
||||
"has_diff": true,
|
||||
"commit_sha": ""
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Manager 裁决规则
|
||||
| Runtime 事实 | display_status |
|
||||
|---|---|
|
||||
| `completed` + has_deliverable + !summary_only | `completed` |
|
||||
| `completed` + summary_only / 无 artifact | `completed_without_deliverable` 或 `needs_codegen` |
|
||||
| `failed` | `failed` / `stopped` / `waiting_approval` 同名透传 |
|
||||
|
||||
### 5.4 Manager 落点
|
||||
- `controller/agnet_callback.go`(`applyAgnetCallbackDeploymentState` 消费 `deliverable`,算 verdict)
|
||||
- `model/agnet_deployment.go`(加 `DeliveryVerdict` / display_status 列,三库兼容 `varchar`)
|
||||
- `controller/agnet_control_plane.go`(detail/timeline 暴露字段)
|
||||
- 复用已就绪的 `artifactIsSummaryOnly`(P2)+ usage
|
||||
- **阶段化**:先加派生字段不改 `status`(向后兼容)→ 客户端跟进后再引入新终态值。
|
||||
|
||||
## 6. 模型策略(总纲 §9)
|
||||
|
||||
- Sub Agile:`per_role` / `default`;Swarm:`primary`。
|
||||
- 单一来源默认模型(P5 已起步,待改名 `defaultAgentModelID`)。
|
||||
- Manager 校验:模型存在 / 套餐允许 / 角色允许 / 预算 / Runtime 支持。
|
||||
|
||||
## 7. 项目文件夹产物 `project_folder`(总纲 §12)—— 中期大件
|
||||
|
||||
- artifact 升为两级:`Project Artifact` → `File Artifacts / Directory Entries`。
|
||||
- 新 artifact_type:`project_folder` / `project_archive`(现有 `code_patch/document/...` 保留)。
|
||||
- 新增接口:`.../artifacts/{id}/manifest`、`.../files/{path}`、`.../archive`。
|
||||
- **本地修改回传 + revision 协议**(§12.7):`local-edits` / `batch` / 冲突 `ARTIFACT_REVISION_CONFLICT` / Manager 维护 current accepted revision。
|
||||
- 影响:新 model(artifact revision / project entries)、新 controller、content 代理扩展。**工程量大,单独立项。**
|
||||
|
||||
## 8. 云部署生命周期(总纲 §18)—— 远期
|
||||
|
||||
`project_folder` → 选 target(Azure/阿里云/AWS) → Manager 校验/审批/凭证/预算 → Deploy Worker/Runtime 执行 → `deployment_manifest` artifact 回传。**远期,本轮不展开。**
|
||||
|
||||
## 9. 安全与审计(总纲 §16/§17)
|
||||
|
||||
| 项 | 现状 | 待办 |
|
||||
|---|---|---|
|
||||
| `secret_ref` 强制 `azkv://` | ✅ agnet 路径 + Resource CRUD(P6a) | 覆盖其余写入路径 |
|
||||
| 值级密钥扫描 | ✅ P6b | — |
|
||||
| 高危操作 Manager gate | ❌ 现为立即 `accepted`,阻断依赖 Runtime | Manager 侧加 risk_level/pending 审批 gate(关联 P1/§17.1) |
|
||||
| 短期凭证 lease 真派生 | ❌ 占位(只发 `lease://`,不 mint) | 接 Key Vault 派生短期凭证 |
|
||||
| revoke 同步 Runtime | ⚠️ approve 有 sync,revoke 无 | 补 revoke→Runtime 通知 |
|
||||
| 审计 fail-closed | ⚠️ best-effort | 关键审计改 fail-closed 或告警 |
|
||||
| 客户端禁 inline secret_ref | — | Manager 改为接受 `resource_binding_id`,内部映射 secret_ref(§17.6) |
|
||||
|
||||
## 10. 落地阶段(总纲 §20,Manager 承担)
|
||||
|
||||
| 阶段 | Manager 任务 | 自主性 | 依赖 | 状态 |
|
||||
|---|---|---|---|---|
|
||||
| **1 接口/命名** | `/api/agent/*` 别名、`/api/heicode/{sub-agile,swarm}/*`、callback 新路由、`AGENT_*` env 回退、`agnet_id`→`agent_id` 双字段 | ✅ 加法自主 | — | 待开始 |
|
||||
| **2 模式路由** | 按 `mode` 路由两套 `*_RUNTIME_*` | ✅ | 蜂群 token | 机制已就绪 |
|
||||
| **3 模型策略** | per_role/primary 校验 | ✅ | — | P5 起步 |
|
||||
| **4 状态/产物裁决** | display_status + deliverable 字段 + project_folder + revision | 🟡 | Runtime 发结构化事实 | P1/P2 起步 |
|
||||
| **5 日志/回调/调试** | user_logs/debug_logs 分层 + diagnostics + 统一 callback | ✅ | Runtime callback 切换 | diagnostics 已有雏形 |
|
||||
|
||||
## 11. 跨团队依赖(Manager 做不了,需协调)
|
||||
|
||||
1. **agent_management / HeiCode-Swarm**:上报结构化 `deliverable` 事实;接入 `/api/agent/{sub-agile,swarm}/*` 路由;callback 切 `/api/agent/callbacks/runtime-events`;产出真实 `project_folder`。
|
||||
2. **蜂群 Runtime**:`SWARM_RUNTIME_SERVICE_TOKEN` 安全配置 + 修复 single-agent fallback(评审 P7)。
|
||||
3. **客户端(macOS/Windows)**:切新接口、消费 display_status、project 文件树展示、本地 edit 上传、禁 mock/直连(§17)。
|
||||
|
||||
## 12. 风险与红线
|
||||
|
||||
- **`agnet` 是 load-bearing**(路由/env/DB/跨服务契约)——**严禁全局 sed**,必须加法别名 + 兼容期。
|
||||
- DB 物理表/列名暂不改,用 `TableName()` 钉住,避免迁移风险。
|
||||
- 跨服务契约字段改动一律 **dual-emit + dual-accept**,且需与调用方协调切换节奏。
|
||||
- 状态裁决先加字段不覆盖 `status`,避免误伤「真完成但 Runtime 未回 artifact」的任务。
|
||||
|
||||
## 13. 待确认事项
|
||||
|
||||
1. 统一方案仍是 **v0.1**,落地前需与作者对齐版本/范围。
|
||||
2. approval decision 回传路径:`/api/agent/{mode}/deployments/{id}/approvals/{approval_id}` 的最终形态。
|
||||
3. 客户端→Manager 路由用「分模式」还是「统一 `/api/heicode/tasks` + body.mode」。
|
||||
4. `needs_codegen` 与 `completed_without_deliverable` 的判定边界(何时用哪个)。
|
||||
5. project_folder / 云部署的优先级与排期(是否本阶段做)。
|
||||
|
||||
---
|
||||
|
||||
*维护:本文件随实现进度更新;每完成一项在「已完成/状态」列标注 commit。*
|
||||
+5
-7
@@ -7,14 +7,12 @@
|
||||
| [`heicode.md`](./heicode.md) | Heicode 当前产品定位、系统边界和架构共识 |
|
||||
| [`plan.md`](./plan.md) | 按当前共识拆出的实施计划 |
|
||||
| [`heicode-runtime-auth-newapi-secret-design.md`](./heicode-runtime-auth-newapi-secret-design.md) | 用户输入、登录用户复用、NewAPI 扣费映射、Azure Key Vault 凭证托管与短期凭证注入边界 |
|
||||
| [`integration/heicode-hm-template-agent-model.md`](./integration/heicode-hm-template-agent-model.md) | **当前模型**:模板 Agent + 客户端直连,HM 端后端/前端改造说明 |
|
||||
| [`integration/heicode-hm-legacy-teardown.md`](./integration/heicode-hm-legacy-teardown.md) | 旧 sub 任务编排模型的代码/表/前端删除清单 |
|
||||
| [`integration/heicode-desktop-client-api.md`](./integration/heicode-desktop-client-api.md) | 桌面客户端对接 HM(模板 Agent 模型),已生产验证 |
|
||||
| [`integration/heicode-am-contract.md`](./integration/heicode-am-contract.md) | HM ↔ AM(agent_management)接口契约 |
|
||||
| [`integration/heicode-swarm-deferred.md`](./integration/heicode-swarm-deferred.md) | **蜂群(Swarm)现状裁定与后续跟踪入口**:HM 当前不实现 swarm runtime,旧 sub/蜂群文档作废后的上下文迁移映射与归属(AM/Swarm 侧) |
|
||||
| [`integration/telemetry-production-enablement-checklist.md`](./integration/telemetry-production-enablement-checklist.md) | **客户端错误遥测生产开启前置 Checklist(#44 定稿)**:代码侧控制核验、生产配置、隐私/法务签字硬前置、上线/回滚验证;开启 `HEICODE_TELEMETRY_ENABLED` 前必过 |
|
||||
| [`heicode-manager-sub-swarm-progress-checklist.md`](./heicode-manager-sub-swarm-progress-checklist.md) | Heicode Manager sub 模式、瀑布/敏捷、蜂群模式的已完成/未完成/依赖/风险/下一步进度清单 |
|
||||
| [`heicode-manager-standalone-execution-plan.md`](./heicode-manager-standalone-execution-plan.md) | Heicode Manager 端可独立完成任务的执行计划、顺序、验收标准和边界 |
|
||||
| [`integration/Heicode-登录接口对接文档.md`](./integration/Heicode-登录接口对接文档.md) | 已上线登录接口对接文档 |
|
||||
| [`integration/agent-platform-request-contract.md`](./integration/agent-platform-request-contract.md) | Manager 请求 Agent 平台时携带的部署、日志、监控、事件与审计接口参数 |
|
||||
| [`deployment/azure-production-deploy-guardrails.md`](./deployment/azure-production-deploy-guardrails.md) | Azure VM / PostgreSQL / Redis / Agent / NewAPI 生产部署前的安全守卫、环境变量注入和验证计划 |
|
||||
|
||||
旧 Agent API 草案、旧 sub / 蜂群任务编排、旧里程碑、旧架构说明和旧上手材料不再作为实施依据(相关文档已删除)。**蜂群相关文档的作废/迁移映射与「HM 不实现 swarm runtime、新能力归 AM/Swarm 侧跟踪」的结论见 [`integration/heicode-swarm-deferred.md`](./integration/heicode-swarm-deferred.md)(删除≠丢上下文,迁移到此追踪入口)。** 当前实施模型以 `heicode.md`、`plan.md` 与 `integration/heicode-hm-template-agent-model.md` 为准;生产部署操作以安全守卫文档约束,且不得覆盖产品/架构主线。
|
||||
旧 Agent API 草案、旧里程碑、旧架构说明和旧上手材料不再作为实施依据。后续文档和实现以 `heicode.md` 与 `plan.md` 为准;生产部署操作以安全守卫文档约束,且不得覆盖产品/架构主线。
|
||||
|
||||
代码中的过渡期命名、旧接口注释或旧 UI 文案只作为现状参考;若与 `heicode.md` / `plan.md` 冲突,应先更新实现或另行补充当前主线文档,不得恢复旧 Agent/M1-M5 草案作为依据。
|
||||
|
||||
@@ -42,8 +42,6 @@ HEICODE_PUBLIC_BASE_URL=https://code.xinghanlab.com
|
||||
|
||||
### 2.2 Agent Manager / 普通 sub / 蜂群联调配置
|
||||
|
||||
> ⚠️ **2026-06-10 勘误**:下列「普通 sub / 蜂群 + `/api/swarms` + `SWARM_RUNTIME_*`」属**旧「HM 主导编排」模型,已作废**。当前权威模型:单 Agent 走「模板 Agent + AM」(`heicode-am-contract.md`);多 Agent 蜂群归 **`agent_swarm`**(HeiCode Swarm)仓(Master-Agent 编排),HM 侧 deferred,契约待 `agent_swarm#2` 冻结。见 `docs/integration/heicode-swarm-deferred.md`。本节 env 仅为历史记录(蜂群开关当前 `SWARM_RUNTIME_ENABLED=false`)。
|
||||
|
||||
```env
|
||||
AGENT_RUNTIME_ENABLED=true
|
||||
AGENT_RUNTIME_BASE_URL=http://20.212.121.126
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
# Heicode Manager 可独立执行任务计划
|
||||
|
||||
更新时间:2026-05-30
|
||||
负责人范围:Heicode Manager 端
|
||||
用途:后续开发按本文逐项执行、验收和更新状态。
|
||||
|
||||
## 一、核查结论
|
||||
|
||||
本文只列 Manager 端能独立完成的任务。判断标准是:不要求 Heicode 客户端新增功能、不要求蜂群 / Agent Runtime 提供真实接口、不要求 AKS / NATS / Prometheus 等基础设施先上线。
|
||||
|
||||
| 结论 | 说明 |
|
||||
|---|---|
|
||||
| 可以独立做 | Manager 自己的 DB 模型、Go API、前端页面、权限校验、回调接收骨架、artifact 数据模型、幂等、模拟事件和文档口径 |
|
||||
| 不能独立做 | 真实 `swarm_id`、worker claim / heartbeat、真实 handoff / retry、真实 Runtime 日志指标、客户端审批弹窗、短期凭证注入 runtime、SK 工具真实调用结果 |
|
||||
| 当前最大问题 | Manager 本地闭环已基本形成;剩余主要是等待真实 Runtime/客户端产生数据后联调验证 |
|
||||
|
||||
## 二、排除项
|
||||
|
||||
以下任务不放入 Manager 独立开发计划,避免把外部依赖误报为 Manager 可完成。
|
||||
|
||||
| 事项 | 排除原因 | 需要谁配合 |
|
||||
|---|---|---|
|
||||
| 真实创建 Swarm Run 并返回 `swarm_id` | 需要蜂群平台提供 `POST /api/swarms` 或等价生产接口 | 蜂群 / Agent Runtime |
|
||||
| 子 Agent claim、heartbeat、release、timeout | 需要 worker runtime 和任务池 | 蜂群 / Agent Runtime |
|
||||
| handoff、retry、blocked 的真实状态机 | 需要 Runtime 产生任务事件 | 蜂群 / Agent Runtime |
|
||||
| 真实日志流和 CPU/内存/耗时指标 | 需要日志/指标源 | 蜂群 / 基础设施 |
|
||||
| 客户端高危审批主弹窗 | 产品要求审批主体验在客户端 | Heicode 客户端 |
|
||||
| 短期凭证真实注入子 Agent | 需要受控 runtime、身份和网络通道 | 蜂群 / 基础设施 |
|
||||
| SK 工具真实调用结果 | 需要 SK 平台或 Runtime 上报 invocation event | 蜂群 / SK 平台 |
|
||||
| 交付结果回到客户端 | 需要客户端展示和 Runtime artifact 输出 | 客户端 + 蜂群 |
|
||||
|
||||
## 三、执行顺序总览
|
||||
|
||||
| 顺序 | 任务 | 优先级 | 是否 Manager 独立 | 完成后价值 |
|
||||
|---:|---|---|---|---|
|
||||
| 1 | 增加 sub 模式字段 | P0 | 是 | 已完成:明确任务组织方式,支撑瀑布/敏捷状态展示 |
|
||||
| 2 | 打通 HeicodeTask 到 Agent deployment 的 Manager 桥接 | P0 | 是 | 已完成:任务可生成 deployment draft |
|
||||
| 3 | 增加用户态 Agent deployment API | P0 | 是 | 已完成:普通用户可以在自己资源范围内创建/查看/停止 deployment |
|
||||
| 4 | 统一 `/api/swarms` 与 `/api/agent/deployments` 边界 | P0 | 是 | 已完成:为后续蜂群联调留稳定 adapter |
|
||||
| 5 | 建 callback 接收端骨架 | P0 | 是 | 已完成:先接住事件、artifact、审批请求、timeline |
|
||||
| 6 | 建 artifact 数据模型和 API | P0 | 是 | 已完成:交付物/产物摘要落库并可展示 |
|
||||
| 7 | 增加回调幂等和签名/服务身份校验骨架 | P1 | 是 | 已完成:重复回调不重复写入,支持 token/HMAC |
|
||||
| 8 | 持久化 SK snapshot | P1 | 是 | 已完成:容器重启后任务上下文和审计不丢 |
|
||||
| 9 | 任务视角审计聚合 | P1 | 是 | 已完成:按 deployment 聚合 audit/callback/artifact/SK |
|
||||
| 10 | 本地模拟蜂群事件冒烟入口 | P1 | 是 | 已完成:默认模拟 task/blocked/retry/handoff/artifact/approval/timeline |
|
||||
| 11 | 前端页面补齐独立闭环展示 | P1 | 是 | 已完成:展示任务流、deployment、事件、artifact、审批和审计关系 |
|
||||
| 12 | 文档口径清理 | P2 | 是 | 已完成:当前口径以 Azure Key Vault / secret_ref 为准 |
|
||||
| 13 | AWS/GCP 占位提示 | P2 | 是 | 已完成:避免用户误以为 AWS/GCP 已可用 |
|
||||
| 14 | 请求 body 加密策略确认 | P2 | 是 | 已完成:桌面端 sub POST 复用模型调用 V2 body 加密 |
|
||||
| 15 | Runtime 状态诊断与兜底产物识别 | P1 | 是 | 已完成:Manager 可独立查询 Runtime 状态,页面明确区分普通 sub / 蜂群,并提示兜底摘要不是最终交付物 |
|
||||
| 16 | Artifact 完整内容代理下载 | P1 | 是 | 已完成:Manager 校验用户和 artifact 后,通过 Runtime content 接口代理下载完整产物 |
|
||||
|
||||
## 四、任务明细
|
||||
|
||||
### 任务 1:增加 sub 模式字段
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | Manager 能记录任务或部署计划采用 `waterfall` / `agile` 哪种组织方式 |
|
||||
| 修改文件 | `heicode/model/agent_deployment.go`、`heicode/controller/agent_control_plane.go`、`heicode/web/default/src/features/agent-console/api.ts`、`heicode/web/default/src/features/agent-console/create-agent-deployment-sheet.tsx` |
|
||||
| 建议字段 | `sub_mode`,枚举:`waterfall`、`agile`,默认 `agile` |
|
||||
| 验收 | 创建 deployment 后 DB、API response、前端详情都能看到 `sub_mode` |
|
||||
| 测试 | `go test ./controller -run 'TestAgent.*SubMode|TestAgentDeployment'`;`cd web/default && bun run typecheck` |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 不允许写成自由文本。
|
||||
- 旧数据无字段时默认按 `agile` 展示。
|
||||
- 不能把 `sub_mode` 当成蜂群 Runtime 流程,只表示 Heicode 任务组织方式。
|
||||
|
||||
### 任务 2:HeicodeTask 到 Agent deployment 桥接
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | Manager 能从 HeicodeTask 的任务卡生成本地 Agent deployment payload |
|
||||
| 修改文件 | `heicode/controller/agent_control_plane.go`、新增 `heicode/controller/agent_task_bridge.go`、`heicode/web/default/src/lib/heicode-mcp.ts`、`heicode/web/default/src/features/tasks/task-card-view.tsx` |
|
||||
| 新增 API | `POST /api/agent/tasks/:task_id/deployment-draft` 或等价 user-scoped endpoint |
|
||||
| 输入 | task id、sub_mode、预算、资源范围、角色模板 |
|
||||
| 输出 | deployment draft 或创建后的 `deployment_id` |
|
||||
| 测试 | 新增 controller 单测;前端 typecheck |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 任务能关联 `deployment_id` 或返回可提交的 deployment draft。
|
||||
- draft 中不能包含明文密钥,只能出现 `secret_ref`。
|
||||
- 找不到 task 或资源授权不足时返回明确错误。
|
||||
|
||||
### 任务 3:用户态 Agent deployment API
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | 普通用户可以创建、查询、停止自己资源范围内的 deployment |
|
||||
| 修改文件 | `heicode/router/api-router.go`、`heicode/controller/agent_control_plane.go`、`heicode/controller/agent_control_plane_test.go` |
|
||||
| 当前问题 | `/api/agent/deployments` 走 `AdminAuth` |
|
||||
| 新增建议 | 保留 admin route;新增 user route:`/api/agent/user/deployments` 或在同一路由中按 user scope 限制 |
|
||||
| 测试 | 普通用户创建成功;越权查询别人 deployment 失败;停止别人 deployment 失败 |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 用户只能看到自己的 deployment。
|
||||
- `user_context.user_id` 为空时用登录用户 id 填充。
|
||||
- 请求体伪造别人 `user_context.user_id` 必须被覆盖或拒绝。
|
||||
- resource grant 必须属于当前用户。
|
||||
|
||||
### 任务 4:统一 `/api/swarms` 与 `/api/agent/deployments` 边界
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | Manager 内部形成生产蜂群接口 adapter,不再让调用方混淆两个口径 |
|
||||
| 修改文件 | 新增 `heicode/controller/agent_swarm_adapter.go` 或 `heicode/service/agent_swarm_adapter.go`,更新 `docs/integration/agent-platform-request-contract.md` |
|
||||
| 当前现实 | 本地已有 `/api/agent/deployments`,蜂群资料包目标接口是 `/api/swarms` |
|
||||
| 独立做法 | 先实现 Manager 内部 adapter 和统一 DTO,真实外呼先留配置开关,默认走本地 control-plane |
|
||||
| 测试 | adapter 单测验证 payload 字段、`secret_ref`、correlation_id、sub_mode |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 文档明确本地 control-plane 与生产 Runtime 的关系。
|
||||
- 未来切换真实蜂群平台时,不需要重写前端页面。
|
||||
- adapter 默认不外呼,避免误触发不存在的生产 Runtime。
|
||||
|
||||
### 任务 5:callback 接收端骨架
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | Manager 先具备接收蜂群平台回调的 API 和落库能力 |
|
||||
| 修改文件 | `heicode/model/agent_callback.go`、`heicode/controller/agent_callback.go`、`heicode/router/api-router.go` |
|
||||
| 新增接口 | `POST /api/agent/callbacks/swarm-events` |
|
||||
| 独立能力 | 本地模拟 payload 可保存、去重、查询;已补普通 sub 任务事件 schema 校验 |
|
||||
| 测试 | controller 单测覆盖正常保存、重复 event_id 幂等、明文密钥拒绝、handoff 缺字段拒绝 |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 请求体不得出现 token/password/private_key/access_key/connection_string 明文字段。
|
||||
- 每个回调都有 `event_id` 或 `idempotency_key`。
|
||||
- 重复回调返回成功但不重复写入。
|
||||
- `task.*` / `handoff.*` 关键事件缺少必填字段时返回 `CALLBACK_SCHEMA_INVALID`。
|
||||
|
||||
### 任务 6:artifact 数据模型和 API
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | Manager 保存和展示 artifact 摘要,不等 Runtime 真实输出 |
|
||||
| 修改文件 | 新增 `heicode/model/agent_artifact.go`、`heicode/controller/agent_artifact.go`、`heicode/web/default/src/features/agent-console/api.ts`、`heicode/web/default/src/features/agent-console/pages.tsx` |
|
||||
| 字段 | `artifact_id`、`deployment_id`、`task_id`、`correlation_id`、`artifact_type`、`title`、`summary`、`uri`、`checksum`、`metadata_json`、`created_at` |
|
||||
| 测试 | model/controller 单测;前端 typecheck |
|
||||
|
||||
验收标准:
|
||||
|
||||
- artifact 只保存摘要和引用,不保存大文件正文。
|
||||
- `uri` 支持 `artifact://`、`git://`、`azblob://`、`https://`,但页面只展示安全摘要。
|
||||
- 能按 `deployment_id` 查询 artifact 列表。
|
||||
|
||||
### 任务 7:回调幂等和服务身份校验骨架
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | 生产联调前先有幂等和认证形状 |
|
||||
| 修改文件 | `heicode/controller/agent_callback.go`、新增 `heicode/middleware/agent_callback_auth.go` |
|
||||
| 机制 | `X-Request-Id`、`X-Correlation-Id`、`Idempotency-Key`、可选 `X-Agent-Signature` |
|
||||
| 当前阶段 | 可以先用配置开关和本地测试 token,不接真实 Key Vault service token |
|
||||
| 测试 | 缺少服务 token 时拒绝;重复 key 不重复写入 |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 开发环境可配置跳过严格签名,但生产默认要求服务身份。
|
||||
- 日志不打印 token 或签名原文。
|
||||
- 幂等冲突能返回已有记录摘要。
|
||||
|
||||
### 任务 8:持久化 SK snapshot
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | 替换当前 `agentSnapshots` 内存 map |
|
||||
| 修改文件 | 新增 `heicode/model/agent_sk_snapshot.go`,修改 `heicode/controller/agent_control_plane.go` |
|
||||
| 当前问题 | 容器重启后 `/sk-snapshots` 丢失 |
|
||||
| 测试 | 创建 snapshot 后清空内存,再从 DB 查询仍存在 |
|
||||
|
||||
验收标准:
|
||||
|
||||
- `deployment_id`、`snapshot_id` 有索引。
|
||||
- 查询按创建时间倒序或稳定顺序返回。
|
||||
- 不保存 SK 内容正文,只保存来源和版本引用。
|
||||
|
||||
### 任务 9:任务视角审计聚合
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | 按 task/deployment/correlation_id 聚合 Manager 已有记录 |
|
||||
| 修改文件 | `heicode/controller/agent_control_plane.go`、`heicode/model/agent_audit.go`、`heicode/web/default/src/features/agent-console/pages.tsx` |
|
||||
| 聚合内容 | deployment、audit events、approvals、leases、artifacts、callbacks、resource grants |
|
||||
| 新增接口 | `GET /api/agent/tasks/:task_id/timeline` 或 `GET /api/agent/deployments/:id/timeline` |
|
||||
| 测试 | 同一 correlation_id 下能聚合多类事件 |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 缺少某类数据时返回空数组,不报错。
|
||||
- 时间线按时间排序。
|
||||
- 敏感字段统一脱敏。
|
||||
|
||||
### 任务 10:本地模拟蜂群事件冒烟入口
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | 在蜂群 Runtime 未接入前,Manager 能用模拟事件自测完整链路 |
|
||||
| 修改文件 | `heicode/controller/agent_callback.go`、`heicode/router/api-router.go`、可选新增 `heicode/controller/agent_smoke.go` |
|
||||
| 接口建议 | admin-only `POST /api/agent/dev/simulate-run` |
|
||||
| 生成内容 | deployment accepted、task.created、task.claimed、task.heartbeat、task.blocked、handoff.requested、handoff.completed、task.retried、task.completed、artifact.created、approval.requested、timeline.updated |
|
||||
| 测试 | 单测验证模拟后 timeline/artifacts/audit 可查;`scripts/agent_sub_mode_smoke.py` 可跑生产健康和 deployment timeline 冒烟 |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 该接口必须 admin-only 或 dev-only。
|
||||
- 响应明确 `simulated: true`。
|
||||
- 线上页面不能把模拟事件显示成真实 Runtime 事件。
|
||||
- task/blocked/retry/handoff 事件能在 timeline 中看到,来源标记为 `simulated`。
|
||||
|
||||
### 任务 11:前端页面补齐独立闭环展示
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | 用户能看到 Manager 自己可提供的闭环信息 |
|
||||
| 修改文件 | `heicode/web/default/src/features/agent-console/pages.tsx`、`api.ts`、必要时新增组件 |
|
||||
| 展示内容 | sub_mode、deployment 来源 task、callbacks、artifacts、timeline、SK snapshots 持久化状态 |
|
||||
| 测试 | `cd heicode/web/default && bun run typecheck`;本地页面点击冒烟 |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 页面明确区分 `control-plane placeholder`、`simulated`、`runtime` 来源。
|
||||
- 没有 artifact/callback 时有空态。
|
||||
- 文案不宣称真实蜂群已完成。
|
||||
|
||||
### 任务 12:文档口径清理
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | 清理旧 Vault/OpenBao/Secret Provider 误导表述 |
|
||||
| 修改文件 | `docs/heicode.md`、`docs/plan.md`、`docs/heicode-manager-sub-swarm-progress-checklist.md` |
|
||||
| 规则 | 用户侧叫“密钥保管器”,当前实现侧写 Azure Key Vault |
|
||||
| 验收 | `rg -n "OpenBao|HashiCorp Vault|Secret Provider" docs` 后剩余内容必须是历史说明或明确非当前实现 |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 不把 Azure Key Vault 写成普通用户要进入的后台。
|
||||
- 不删除历史架构背景时,必须标注“历史/非当前实现”。
|
||||
|
||||
### 任务 13:AWS/GCP 占位提示
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | 避免用户误以为 AWS/GCP 已完成 |
|
||||
| 修改文件 | 资源绑定相关前端页面、`heicode/controller/resource.go` 如需补充状态字段 |
|
||||
| 当前现实 | Azure 已有第一阶段发现,AWS/GCP 未实现 |
|
||||
| 验收 | UI 明确显示 AWS/GCP “即将支持”或禁用状态 |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 禁用项不能提交到后端创建真实资源发现。
|
||||
- 已有 Azure 流程不受影响。
|
||||
|
||||
### 任务 14:请求 body 加密策略确认
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | 桌面客户端调用 Manager 的 sub 流程接口时,支持与模型调用一致的 V2 应用层 body 加密;浏览器后台页面继续兼容普通 HTTPS JSON |
|
||||
| 修改文件 | `docs/integration/heicode-desktop-sub-agile-api.md`、`docs/heicode-runtime-auth-newapi-secret-design.md`、必要时补充 `heicode/middleware` 或客户端 SDK 加解密模块 |
|
||||
| 当前现实 | `/api/agent/user/*` 与 `/api/heicode-auth/*` 已支持 `Content-Encoding: heicode-aead-v1`;Manager 会先解密并校验设备签名,再把明文 JSON 交给业务处理或代理给上游 |
|
||||
| 安全红线 | 请求体、回调、日志、artifact metadata、timeline 中禁止出现明文密码、Token、私钥、连接串、云 access key、模型 key;长期凭证只传 `secret_ref` |
|
||||
| 后续判断 | 桌面客户端需要在 sub 流程调用中复用模型调用的 encryptedFetch/V2 签名能力;服务端保留未加密 JSON 只用于 Manager Web 控制台兼容 |
|
||||
| 测试 | 单测覆盖 V2 malformed 请求拒绝、普通代理请求兼容;生产需用真实已配对设备发起加密 sub 请求冒烟 |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 文档明确桌面端优先走 V2 应用层 body 加密,且保留浏览器后台未加密 JSON 兼容。
|
||||
- 客户端对接文档明确禁止传明文长期凭据,只允许 `secret_ref` / `lease://...` / 资源元数据。
|
||||
- 服务端日志不打印请求 body 中的敏感字段。
|
||||
- V2 加密失败时返回 `X-Heicode-Auth-Error` 和 `X-Heicode-Server-Time`,便于客户端排障。
|
||||
|
||||
### 任务 15:Runtime 状态诊断与兜底产物识别
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | Manager 不依赖客户端或 Runtime 改接口,也能把“callback 已到”和“Runtime/Agent 是否真的产出交付物”分开展示 |
|
||||
| 修改文件 | `heicode/controller/agent_runtime_client.go`、`heicode/router/api-router.go`、`heicode/web/default/src/features/agent-console/api.ts`、`heicode/web/default/src/features/agent-console/pages.tsx` |
|
||||
| 新增 API | `GET /api/agent/user/deployments/:deployment_id/runtime-diagnostics` |
|
||||
| 诊断来源 | 用 deployment 记录里的 `runtime_swarm_id` / `runtime_deployment_id` 查询 Runtime status;默认路径 `/api/swarms/{swarm_id}/status`,可用 `AGENT_RUNTIME_STATUS_PATH` / `SWARM_RUNTIME_STATUS_PATH` 配置 |
|
||||
| 普通 sub / 蜂群边界 | 返回 `runtime_mode`,页面分别显示“普通 sub 模式”或“蜂群模式”,不把二者合并成一个流程 |
|
||||
| 已识别异常 | Runtime agent failed、completed 但存在 failed agents、只有 `Runtime execution summary` 兜底产物、模型 token 用量为 0、Runtime 状态查询失败 |
|
||||
| 测试 | `go test ./controller -run TestAgentRuntimeDiagnosticsWarnsOnCompletedRuntimeWithFailedAgents -count=1`;前端 `bun run build:check` |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 诊断接口只读,不改 deployment 状态,不伪造 artifact。
|
||||
- 页面中的兜底摘要必须明确提示“不是最终业务交付物”。
|
||||
- 普通 sub 与蜂群模式必须通过 `runtime_mode` 区分展示。
|
||||
- Runtime 状态查不到时返回可读 warning,不能影响 Manager 已落库 callback/timeline/artifact 查询。
|
||||
|
||||
### 任务 16:Artifact 完整内容代理下载
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 目标 | 对齐 Agent Manager v2.1.10 的产物获取流程:Manager / 前端先查 artifact 列表,再通过用户态 content 代理接口获取完整文件 |
|
||||
| 修改文件 | `heicode/model/agent_artifact.go`、`heicode/controller/agent_callback.go`、`heicode/controller/agent_runtime_client.go`、`heicode/router/api-router.go`、`heicode/web/default/src/features/agent-console/pages.tsx` |
|
||||
| 新增 API | `GET /api/agent/user/deployments/:deployment_id/artifacts/:artifact_id/content` |
|
||||
| Runtime 目标路径 | 默认 `GET /api/swarms/{swarm_id}/artifacts/{artifact_id}/content`,可用 `AGENT_RUNTIME_ARTIFACT_CONTENT_PATH` / `SWARM_RUNTIME_ARTIFACT_CONTENT_PATH` 配置 |
|
||||
| 安全边界 | Manager 先校验当前用户拥有 deployment,再校验 artifact 属于该 deployment;不暴露 Azure Blob 凭据、SAS URL 或 Runtime 内网信息 |
|
||||
| 测试 | `go test ./controller -run TestAgentArtifactContentProxiesRuntimeContent -count=1` |
|
||||
|
||||
验收标准:
|
||||
|
||||
- 不能直接用用户传入的 URI 下载,必须以 Manager 已落库 artifact 为准。
|
||||
- 响应透传 Runtime 的文件正文、`Content-Type` 和 `Content-Disposition`。
|
||||
- Runtime 未配置或 content 拉取失败时返回明确业务错误。
|
||||
- 页面 artifact 卡片提供“下载产物”,但兜底摘要仍需标记为非最终业务交付物。
|
||||
|
||||
## 五、推荐执行批次
|
||||
|
||||
### 批次 A:最小 Manager 闭环
|
||||
|
||||
| 顺序 | 任务 |
|
||||
|---:|---|
|
||||
| 1 | sub 模式字段 |
|
||||
| 2 | 用户态 Agent deployment API |
|
||||
| 3 | HeicodeTask 到 deployment 桥接 |
|
||||
| 4 | 本地模拟蜂群事件冒烟入口 |
|
||||
|
||||
完成批次 A 后,Manager 应能做到:普通用户从任务卡发起一个本地 deployment,并通过模拟事件看到任务进展,不依赖真实 Runtime。
|
||||
|
||||
### 批次 B:生产联调准备
|
||||
|
||||
| 顺序 | 任务 |
|
||||
|---:|---|
|
||||
| 1 | `/api/swarms` 与 `/api/agent/deployments` adapter |
|
||||
| 2 | callback 接收端骨架 |
|
||||
| 3 | artifact 数据模型和 API |
|
||||
| 4 | 回调幂等和服务身份校验骨架 |
|
||||
|
||||
完成批次 B 后,Manager 应能接收蜂群平台未来回调,并能用模拟 payload 证明幂等、落库、查询和脱敏正确。
|
||||
|
||||
### 批次 C:可观测与收尾
|
||||
|
||||
| 顺序 | 任务 |
|
||||
|---:|---|
|
||||
| 1 | SK snapshot 持久化 |
|
||||
| 2 | 任务视角审计聚合 |
|
||||
| 3 | 前端闭环展示 |
|
||||
| 4 | 文档口径清理 |
|
||||
| 5 | AWS/GCP 占位提示 |
|
||||
| 6 | 请求 body 加密策略确认 |
|
||||
| 7 | Runtime 状态诊断与兜底产物识别 |
|
||||
| 8 | Artifact 完整内容代理下载 |
|
||||
|
||||
完成批次 C 后,Manager 端应具备清晰的任务视角、持久化上下文、准确页面口径和更少误导。
|
||||
|
||||
## 六、统一自测命令
|
||||
|
||||
每个批次完成后至少执行:
|
||||
|
||||
```bash
|
||||
cd /Users/gongzhiyong/go/heicode-mananger/heicode
|
||||
go test ./controller ./model
|
||||
```
|
||||
|
||||
前端有改动时执行:
|
||||
|
||||
```bash
|
||||
cd /Users/gongzhiyong/go/heicode-mananger/heicode/web/default
|
||||
bun run typecheck
|
||||
bun run build
|
||||
```
|
||||
|
||||
文档有改动时执行:
|
||||
|
||||
```bash
|
||||
cd /Users/gongzhiyong/go/heicode-mananger
|
||||
git diff --check -- docs
|
||||
```
|
||||
|
||||
如果涉及生产部署,必须再按:
|
||||
|
||||
```bash
|
||||
cd /Users/gongzhiyong/go/heicode-mananger
|
||||
sed -n '1,220p' docs/deployment/azure-production-deploy-guardrails.md
|
||||
```
|
||||
|
||||
## 七、完成定义
|
||||
|
||||
Manager 独立任务完成,不等于蜂群生产闭环完成。本文完成的定义是:
|
||||
|
||||
| 条件 | 标准 |
|
||||
|---|---|
|
||||
| 普通用户路径 | 非管理员用户可以基于自己的任务和资源创建/查看/停止本地 deployment |
|
||||
| 任务关联 | HeicodeTask 能关联 deployment 或 deployment draft |
|
||||
| sub 模式 | `waterfall` / `agile` 在 DB、API、UI 可追踪 |
|
||||
| 回调骨架 | 事件、artifact、approval-request、usage、status 可模拟回调落库 |
|
||||
| artifact | artifact 摘要可保存、查询、展示 |
|
||||
| 幂等 | 重复回调不重复写入 |
|
||||
| 审计 | task/deployment/correlation_id 下能聚合审计、审批、artifact、callback |
|
||||
| 安全 | API、日志、页面不出现明文长期密钥 |
|
||||
| 请求加密 | 桌面端 sub 请求支持与模型调用一致的 V2 body 加密;浏览器后台普通 JSON 兼容路径不受影响 |
|
||||
| Runtime 诊断 | Manager 页面能显示 Runtime 真实状态来源、失败 Agent、兜底产物和普通 sub / 蜂群模式归属 |
|
||||
| Artifact 内容 | Manager 用户态接口能代理下载 Runtime 保存的完整 artifact 内容,且不暴露存储凭据 |
|
||||
| 口径 | 页面和文档不把本地占位/模拟事件说成真实 Runtime |
|
||||
|
||||
## 八、执行时不能突破的边界
|
||||
|
||||
1. 不在 Manager 中保存长期明文密钥。
|
||||
2. 不让普通用户看到 CodeGW 管理后台能力。
|
||||
3. 不把 Manager 做成网页编码主体验。
|
||||
4. 不把 `sub_mode` 解释成蜂群 Runtime 固定流程。
|
||||
5. 不把本地模拟事件当真实蜂群完成证据。
|
||||
6. 不把 Azure Key Vault 暴露成普通用户要直接操作的后台。
|
||||
7. 不绕过客户端高危审批的产品边界;Manager 只能先提供记录和 API。
|
||||
8. 不破坏 V2 body 加密的版本字段、诊断头、重放保护和未加密 Web 控制台兼容路径,避免客户端和 Manager 联调不可排障。
|
||||
@@ -0,0 +1,144 @@
|
||||
# Heicode Manager 普通 sub 与蜂群模式进度清单
|
||||
|
||||
更新时间:2026-05-30
|
||||
负责人范围:Heicode Manager 端
|
||||
用途:给负责人、上级和联调同学快速确认 Manager 端在普通 sub 与蜂群模式下已经具备什么、还要做什么、哪些需要客户端或 Agent Manager / 蜂群项目配合。
|
||||
|
||||
## 资料来源
|
||||
|
||||
| 来源 | 用途 |
|
||||
|---|---|
|
||||
| `http://gitee.ath.cx:3000/taijibaga/fengqun/src/branch/main/docs` | 蜂群设计资料包,定义目标驱动蜂群、任务图、claim、heartbeat、handoff、artifact、审批、审计和三方分工 |
|
||||
| `http://gitee.ath.cx:3000/taijibaga/HeiCode-Swarm` | 蜂群项目实现资料,当前 Orchestrator/Agent/Redis/K8s/桌面演示客户端的实际结构 |
|
||||
| `docs/product-package/07-integration-boundaries.md` | Heicode、Manager、Agent 平台、CodeGW、Azure Key Vault 的边界 |
|
||||
| `docs/integration/heicode-desktop-sub-agile-api.md` | Heicode 桌面客户端接 Manager 的普通 sub 敏捷流程 |
|
||||
| `docs/integration/Heicode-Manager-PayPal支付接入与计费关系说明.md` | PayPal 收款、Heicode 余额/订阅、NewAPI 模型扣费和 Agent 运行费用边界 |
|
||||
| 当前仓库 `heicode/` 代码 | Manager 端实际实现核查 |
|
||||
|
||||
## 一、核心边界
|
||||
|
||||
蜂群模式不是普通 sub 敏捷/瀑布本身。普通 sub 是 Heicode 的任务组织方式;蜂群是 Agent/Swarm Runtime 的执行方式。
|
||||
|
||||
| 模式 | Manager 当前职责 | Runtime / Agent Manager 当前职责 | 不能混淆的点 |
|
||||
|---|---|---|---|
|
||||
| 普通 sub 敏捷/瀑布 | 从任务卡生成 deployment、保存 `sub_mode`、权限清单、预算、callback、timeline、artifact、审批记录和运行时诊断 | 执行普通 sub 任务,回传阶段、日志、工具调用、artifact、usage、失败原因 | 普通 sub 不等于蜂群 task graph;`/api/swarms` 可作为 Runtime 兼容入口,但页面和文档必须按普通 sub 展示 |
|
||||
| 蜂群模式 | 生成 swarm adapter 请求、保存 `deployment_id <-> swarm_id` 映射、接回调、审批、审计、artifact 展示和运行时诊断 | 创建 Swarm Run、任务图、claim、heartbeat、handoff、Agent 编队、真实执行与结果回传 | 蜂群是执行形态;不能把普通 sub 的阶段状态误写成蜂群已完成 |
|
||||
|
||||
| 系统 | 定位 | 应该做什么 | 不应该做什么 |
|
||||
|---|---|---|---|
|
||||
| Heicode 桌面客户端 | 用户主体验 | 输入目标、持续补充需求、查看反馈、审批高危操作、接收交付结果 | 直接配置 AKS、模型供应商、完整蜂群 payload |
|
||||
| Heicode Manager | 控制面、记录面和用户侧账本入口 | 资源绑定、`secret_ref`、权限清单、生成启动请求、记录 deployment/swarm 映射、回调、artifact、timeline、审批、审计、余额/订阅展示 | 替代客户端做主开发对话,替代 Runtime 执行任务,或把 PayPal 收款当成模型扣费链路 |
|
||||
| HeiCode-Swarm / Agent Runtime | 执行层 | 创建 Swarm Run、任务图、Agent 编队、claim、heartbeat、handoff、执行、结果回传、真实运行 usage 回传 | 保存长期明文密钥,直接暴露给普通用户,或自行决定用户账本扣费 |
|
||||
|
||||
## 二、目标调用链
|
||||
|
||||
```text
|
||||
Heicode 桌面客户端
|
||||
-> Heicode Manager
|
||||
- V2 加密请求 body
|
||||
- task/deployment draft
|
||||
- resource_grants / secret_ref / budget / approval_policy
|
||||
-> Agent Runtime 或 HeiCode-Swarm
|
||||
- POST /api/swarms 或兼容创建入口
|
||||
- 返回 swarm_id / runtime_deployment_id
|
||||
<- Runtime callback
|
||||
- swarm-events / artifact.created / approval.requested / timeline.updated
|
||||
<- Manager 查询接口
|
||||
- deployment detail / events / logs / metrics / artifacts / sk-snapshots / timeline
|
||||
<- 桌面客户端展示和审批
|
||||
```
|
||||
|
||||
## 三、当前 Manager 已完成项
|
||||
|
||||
以下只按当前仓库代码确认,不把规划项写成已完成。
|
||||
|
||||
| 能力 | 当前状态 | 代码证据 |
|
||||
|---|---|---|
|
||||
| sub 模式字段 | 已支持 `sub_mode`,默认 `agile`,校验 `agile/waterfall` | `heicode/controller/agent_control_plane.go`、`heicode/model/agent_deployment.go` |
|
||||
| 用户态 deployment | 已有 `/api/agent/user/deployments` 创建、查询、停止、日志、事件、指标、artifact、SK snapshot、timeline | `heicode/router/api-router.go` |
|
||||
| 任务到 deployment draft | 已有 `/api/agent/user/tasks/:task_id/deployment-draft` | `heicode/controller/agent_task_bridge.go` |
|
||||
| `/api/swarms` 兼容入口 | 已有用户态 `POST /api/swarms`,内部走 Manager deployment 创建,并作为 adapter source 记录 | `heicode/router/api-router.go`、`AgentCreateUserSwarm` |
|
||||
| Runtime 创建桥接 | 已能按配置调用 Runtime 创建接口,默认路径 `/api/agent/deployments`,可用环境变量改为蜂群创建路径 | `heicode/controller/agent_runtime_client.go` |
|
||||
| Runtime stop 桥接 | 已能在停止 Manager deployment 时调用 Runtime stop | `heicode/controller/agent_runtime_client.go` |
|
||||
| Runtime 状态诊断 | 已新增用户态只读诊断接口,按 `runtime_mode` 区分普通 sub / 蜂群,查询 Runtime status 并识别 failed agent、兜底摘要 artifact、零 token 用量等异常 | `heicode/controller/agent_runtime_client.go`、`AgentGetUserDeploymentRuntimeDiagnostics` |
|
||||
| callback 接收 | 已有 `POST /api/agent/callbacks/swarm-events` | `heicode/controller/agent_callback.go` |
|
||||
| callback 鉴权 | 支持 `X-Agent-Service-Token` 和 HMAC 签名校验,并可从 Key Vault ref 读取签名密钥 | `heicode/controller/agent_callback.go` |
|
||||
| callback 幂等 | `event_id` / `idempotency_key` 去重,重复回调返回成功但不重复写 | `heicode/model/agent_callback.go` |
|
||||
| artifact 落库 | `artifact.created` 可生成 artifact 记录,支持用户态列表查询 | `heicode/model/agent_artifact.go`、`AgentListUserDeploymentArtifacts` |
|
||||
| artifact 完整内容代理 | 已新增用户态 content 下载接口,Manager 校验 deployment/artifact 权限后代理 Runtime content 接口读取完整产物 | `AgentGetUserDeploymentArtifactContent`、`callAgentRuntimeArtifactContent` |
|
||||
| approval 回调 | `approval.requested` 可转成 Manager 审批记录 | `heicode/controller/agent_callback.go` |
|
||||
| 审批结果回传 Runtime | 用户 approve/reject 后,Manager 可按配置 POST 回 Runtime approval decision,且不发送 `secret_ref` | `heicode/controller/agent_approval.go`、`heicode/controller/agent_runtime_client.go` |
|
||||
| timeline 聚合 | 用户态 timeline 聚合 audit、callbacks、artifacts、sk_snapshots | `AgentGetUserDeploymentTimeline` |
|
||||
| SK snapshot 持久化 | 已有 `agent_sk_snapshots` 模型和列表查询 | `heicode/model/agent_sk_snapshot.go` |
|
||||
| 本地模拟事件 | 已有用户态 `simulate-events`;默认模拟会写入 callback、artifact、approval、timeline 记录,用于 Manager 自测展示链路和脱敏检查 | `AgentSimulateUserDeploymentEvents` |
|
||||
| V2 body 加密 | `/api/agent/user/*`、`/api/heicode-auth/*`、`/api/swarms` 已按同一套 V2 设备签名和 body 加密路径设计;未加密 Web 控制台仍兼容 session + `New-Api-User` | `heicode/middleware/auth.go`、`heicode/router/api-router.go` |
|
||||
| 生产普通 sub 烟测记录 | 2026-05-31 已用生产 Manager 入口完成真实普通 sub 复核:`dep_1d6d66896cc6` -> `swm_03995f7c7a27`,`gpt-5.4`,`tokens_used=2682`,`newapi_request_id=chatcmpl-DlbZce3VZnsv5DptBILFoiRYy5ZHN`,业务 `code_patch` artifact 可通过 Manager content 接口下载 | `docs/integration/heicode-desktop-sub-agile-api.md` |
|
||||
| PayPal/计费边界文档 | 已明确 PayPal 只是收款渠道;模型调用仍走 Heicode/NewAPI 的钱包或订阅额度;Agent 运行费用目前只有预算字段,真实收费需 Runtime usage 回传 | `docs/integration/Heicode-Manager-PayPal支付接入与计费关系说明.md` |
|
||||
|
||||
## 四、Manager 端还需要继续做的蜂群任务
|
||||
|
||||
| 优先级 | 任务 | 当前缺口 | 是否 Manager 可独立做 | 验收标准 |
|
||||
|---|---|---|---|---|
|
||||
| P0 | 把 `/api/swarms` adapter 文档化并固定字段 | 已完成:`docs/integration/蜂群模式-AgentManager对接任务清单.md` 和 `docs/integration/普通sub敏捷模式-AgentManager对接任务清单.md` 已写清 Runtime create、callback、source、`deployment_id <-> swarm_id` 映射 | 是 | 文档可直接发给 Agent Manager / 蜂群侧核对 |
|
||||
| P0 | 增加 Swarm Run 显示字段 | 已完成:用户态 deployment 详情展示 `runtime_deployment_id`、`runtime_swarm_id`、`runtime_state`,并在相关记录里展示 source | 是 | 用户态 deployment 详情和后台页面能看到 Runtime 映射 |
|
||||
| P0 | 回调事件类型收敛 | 已完成:`GET /api/agent/callbacks/swarm-events/schema` 输出事件类型、分类和必填字段;callback 接收端按 schema 校验关键 task/handoff/artifact/approval 字段 | 是 | `task.created/claimed/running/completed/failed/handoff/approval/artifact` 都有 schema |
|
||||
| P0 | Runtime 联调配置模板 | 已完成:两份 Agent Manager 对接任务清单已写清 `AGENT_RUNTIME_*`、callback URL、service token/HMAC 方式和验收步骤 | 是 | 蜂群项目按模板能调用 Manager callback |
|
||||
| P1 | 审批结果回传 Runtime 联调 | Manager adapter 已有;仍需要 Runtime 提供接收接口并验证状态继续/停止 | 需要 Runtime 接口 | 审批通过/拒绝后 Runtime 状态能继续或停止 |
|
||||
| P1 | Artifact 展示优化 | Manager 端已完成:页面展示 artifact 类型、摘要和 URI;真实 `code_patch/document/test_report/deployment_manifest` 仍需 Runtime 输出 | 需要 Runtime 数据 | artifact 页面/详情能按类型展示摘要和链接 |
|
||||
| P1 | Artifact 完整内容下载 | 已完成:用户态 `/artifacts/{artifact_id}/content` 代理 Runtime content,页面提供下载入口 | 是 | artifact 属于当前用户 deployment 才能下载,响应透传 Runtime 文件内容 |
|
||||
| P1 | 任务图/Agent 状态展示占位 | 已完成:页面从 `task.*` / `handoff.*` callback 聚合 Agent task map;无真实数据时显示 Runtime callback 空态 | Manager 可先做展示结构,真实数据需 Runtime | 有空态和字段,不宣称真实已运行 |
|
||||
| P1 | Runtime 状态诊断展示 | 已完成:任务总览详情页显示运行模式、运行状态、数据来源和异常 warning;兜底摘要产物会提示“不是最终业务交付物” | 是 | 页面能识别 callback 已到但 Runtime agent 失败、只返回兜底摘要的情况 |
|
||||
| P1 | 日志/指标真实来源标识 | 已完成:logs/metrics API 返回 `data_source`、`runtime_source`,当前明确是 Manager control-plane / estimated,不伪装 Runtime 真实指标 | 需要 Runtime 数据 | 页面和 API 响应能区分来源 |
|
||||
| P1 | Agent 运行费用口径收敛 | 已完成文档口径:`budget.max_tokens/max_cost_usd/max_duration_sec` 是预算约束,不等于真实扣费账本;真实收费必须依赖 Runtime 回传 usage | Manager 已完成文档,真实数据需 Runtime | 页面/文档不把 estimated budget 说成真实扣费 |
|
||||
| P1 | 高危审批客户端联动文档 | 已完成:`docs/integration/heicode-desktop-sub-agile-api.md` 已包含 approval 查询、approve/reject、awaiting_approval 流程 | 是 | 客户端文档补齐 approval flow |
|
||||
| P2 | 蜂群模式验收脚本 | 已完成:`scripts/agent_sub_mode_smoke.py` 支持 schema 检查、生产健康检查、可选 simulate-events、可选真实 callback smoke | 是 | 本地/生产能跑出 callback、artifact、approval、timeline 可见 |
|
||||
| P2 | 生产 schema / 认证链路复测 | 本地代码和测试已覆盖;生产公开 `GET /api/agent/callbacks/swarm-events/schema` 当前返回 404,认证接口需有效登录态或后台 token 才能测 | 是,部署后复测 | 生产 schema 返回 200,用户态/后台态 smoke 能拿到真实数据 |
|
||||
|
||||
## 五、需要蜂群项目配合的事项
|
||||
|
||||
| 事项 | 为什么 Manager 不能单独完成 | 蜂群侧需要提供 |
|
||||
|---|---|---|
|
||||
| 真实 Swarm Run | Manager 只能发起请求和记录,不能替 Runtime 创建任务图 | 生产 `POST /api/swarms` 或确认使用现有 `/tasks` 兼容方式 |
|
||||
| 真实 task graph | 任务拆解、依赖、状态机在 Runtime 内部产生 | `swarm_tasks`、依赖关系、状态枚举 |
|
||||
| claim / heartbeat / release | 这是 worker runtime 行为 | 事件回调或查询接口 |
|
||||
| handoff / retry / blocked | 任务交接和失败恢复属于 Runtime | 标准事件、重试次数、失败原因、下一步动作 |
|
||||
| Agent 执行结果 | Manager 不能生成真实代码产物 | artifact schema、Git branch/commit、测试报告、部署结果 |
|
||||
| Runtime 指标 | CPU、内存、耗时、Agent 存活、任务耗时来自集群 | metrics 查询或 Prometheus 指标映射 |
|
||||
| Runtime 真实用量和成本 | Manager 只能保存预算和回传结果,不能凭本地估算扣真实 Agent 运行费用 | `model_tokens`、`model_cost_usd`、`runtime_seconds`、`cpu_core_seconds`、`memory_mb_seconds` 等 usage callback |
|
||||
| 审批等待状态机 | Runtime 要能暂停高危动作并等待 Manager/客户端审批 | approval request 和 approval decision API |
|
||||
|
||||
## 六、需要桌面客户端配合的事项
|
||||
|
||||
| 事项 | Manager 已有基础 | 客户端需要做 |
|
||||
|---|---|---|
|
||||
| V2 加密请求 | Manager 已支持 | sub/蜂群相关 POST 请求复用模型调用加密 |
|
||||
| 任务创建和追问 | Manager 有 `/api/heicode-auth/*` 代理 | 带 Heicode access token 调用任务接口 |
|
||||
| deployment draft | Manager 有用户态接口 | 从任务卡调用 draft,再创建 deployment/swarm |
|
||||
| 进度展示 | Manager 有 detail/events/timeline/artifacts 接口 | 做用户主体验展示,不暴露底层 payload |
|
||||
| 高危审批 | Manager 有 approval API 和回调转审批记录 | 弹窗展示风险、资源、TTL,并提交 approve/reject |
|
||||
|
||||
## 七、当前不应误报为完成的项
|
||||
|
||||
| 项 | 当前真实状态 |
|
||||
|---|---|
|
||||
| 蜂群生产闭环 | 未完成。Manager 有控制面和回调骨架,但真实 Runtime 任务图/Agent 执行仍需蜂群项目联调 |
|
||||
| HeiCode-Swarm 项目等于正式 Heicode 桌面客户端 | 不是。它有自己的 `desktop-client` 演示端,正式链路应走 Heicode 桌面客户端 -> Manager -> Runtime |
|
||||
| `/api/swarms` 已等于真实 Runtime Swarm Run | 不是。Manager 侧已有 adapter 入口,但是否真实创建 Swarm Run 取决于 Runtime 配置和蜂群接口 |
|
||||
| artifact/timeline 有接口就等于有真实产物 | 不是。Manager 能接和展示,真实产物必须由 Runtime 回调 |
|
||||
| `Runtime execution summary` 就等于最终交付物 | 不是。Manager 页面会标记这是兜底摘要;真实最终交付物必须是 Runtime/Agent 返回的 `code_patch`、`document`、`test_report`、Git branch/commit、部署地址等可核对 artifact |
|
||||
| 高危审批在 Manager 里点完就闭环 | 不是。产品要求桌面客户端主审批,并且 Runtime 要收到 decision |
|
||||
| 本地测试通过就等于生产接口全通 | 不是。本地 router/controller/middleware 测试能证明代码能力;生产仍必须确认对应镜像、路由和认证配置已生效 |
|
||||
| Agent budget 就等于真实收费 | 不是。当前 `budget` 是执行上限和审计字段;真实收费需要 Runtime/Agent Manager 回传可核对 usage |
|
||||
| PayPal 接入会改变模型扣费方式 | 不是。PayPal 只是充值/购买订阅的收款渠道,模型调用仍从钱包余额或内部订阅额度扣 |
|
||||
|
||||
## 八、后续执行顺序
|
||||
|
||||
| 顺序 | 任务 | 负责人范围 | 备注 |
|
||||
|---:|---|---|---|
|
||||
| 1 | 固定 Manager -> Swarm adapter 契约 | Manager | 先把 `/api/swarms`、Runtime create path、callback 字段写死成可联调文档 |
|
||||
| 2 | 跑一次本地模拟 Runtime callback | Manager | 已有默认模拟链路;继续用于验证 callback/artifact/timeline/approval 去重和脱敏 |
|
||||
| 3 | 给蜂群项目配置 callback URL 和 service token | Manager + 蜂群 | 不传明文长期密钥 |
|
||||
| 4 | 用 HeiCode-Swarm 当前 Orchestrator 做兼容测试 | Manager + 蜂群 | 先判断是否走 `/tasks` 适配,还是蜂群侧补 `/api/swarms` |
|
||||
| 5 | 桌面客户端按文档跑任务 -> draft -> create -> timeline -> approval | 客户端 + Manager | 使用 V2 加密 POST |
|
||||
| 6 | 核对 Runtime usage 回传字段 | Manager + 蜂群 | 至少覆盖模型 token/cost、运行时长、Agent role、deployment/task/correlation |
|
||||
| 7 | 补页面来源标识和任务图空态 | Manager | 防止把 simulated/control-plane 误认为 runtime |
|
||||
@@ -0,0 +1,112 @@
|
||||
# Heicode Manager 蜂群模式缺失对照文档
|
||||
|
||||
更新时间:2026-05-27
|
||||
对照范围:`fengqun` 设计资料、`HeiCode-Swarm` 项目现状、Heicode 产品资料包、当前 Heicode Manager 代码。
|
||||
|
||||
## 一、对照结论
|
||||
|
||||
Manager 端已经具备蜂群联调需要的控制面基础:用户态 deployment、task draft、`/api/swarms` adapter、Runtime 创建/停止桥接、callback 接收、artifact、approval、approval decision 回传 adapter、timeline、SK snapshot、V2 加密请求。本地 `simulate-events` 默认链路也能写入 task、blocked、retry、handoff、artifact、approval、timeline 记录,便于 Manager 自测页面展示和脱敏。
|
||||
|
||||
仍然缺的是“真实蜂群 Runtime 产生的数据和状态机”。也就是说,Manager 不是完全没写;缺口主要集中在 Runtime 真正创建 Swarm Run、任务图、Agent 执行、handoff、产物和审批继续/停止闭环。
|
||||
|
||||
## 二、按文档要求逐项对照
|
||||
|
||||
| 文档要求 | 当前 Manager 状态 | 是否满足 | 缺失原因 | 需要谁 |
|
||||
|---|---|---|---|---|
|
||||
| Heicode 客户端是主体验 | Manager 文档和接口已按客户端调用设计,V2 加密 POST 已支持 | 部分满足 | 桌面客户端还需按文档接任务、展示进度和审批 | 客户端 |
|
||||
| Manager 是受控入口,不暴露完整 payload 给普通用户 | 用户态 draft/create 已有,可由任务卡生成 plan | 部分满足 | 页面还需要更弱化底层 payload,突出启动摘要和状态 | Manager |
|
||||
| Manager 调用 `POST /api/swarms` 创建 Swarm Run | Manager 已有 `/api/swarms` adapter,Runtime create path 可配置 | 部分满足 | 是否真实创建 Swarm Run 取决于蜂群 Runtime 是否提供生产接口 | 蜂群 + Manager |
|
||||
| 保存 `swarm_id`、状态、请求摘要、`correlation_id` | 模型已有 `runtime_swarm_id`、`runtime_state`、payload JSON、correlation metadata | 基本满足 | 真实 `swarm_id` 需要 Runtime 返回 | 蜂群 |
|
||||
| 接收 `swarm-events` 回调 | 已有 `POST /api/agent/callbacks/swarm-events` | 满足接收能力 | 还缺真实 Runtime 持续回调 | 蜂群 |
|
||||
| 重复回调幂等 | 已按 `event_id` / `idempotency_key` 去重 | 满足 | 需要蜂群侧稳定传唯一事件 ID | 蜂群 |
|
||||
| 接收 artifact 回调 | `artifact.created` 可落库并查询 | 满足接收能力 | 真实 artifact schema 和文件/分支引用需 Runtime 输出 | 蜂群 |
|
||||
| 展示 Swarm 状态、事件、产物 | deployment detail/events/artifacts/timeline 已有,页面已强化 runtime/simulated 来源和 artifact 类型/URI | 基本满足 | 真实展示内容仍依赖 Runtime 回调真实数据 | 蜂群 |
|
||||
| 展示 task graph、claim、heartbeat | Manager 已可接收并展示普通 task flow callback;没有独立 task graph 状态表 | 部分满足 | 真实任务图仍需要 Runtime 产出 task/agent 事件 | 蜂群 |
|
||||
| 展示 handoff、blocked、retry | 已补 `task.*` / `handoff.*` schema 校验和页面任务流展示 | 基本满足接收和展示 | 真实数据仍需要 Runtime 持续回调 | 蜂群 |
|
||||
| 高危审批请求进入 Manager | `approval.requested` callback 可转审批记录;用户 approve/reject 后可按配置 POST 回 Runtime | 部分满足 | 还需要蜂群 Runtime 提供并验证 approval decision 接收接口 | Manager + 蜂群 |
|
||||
| 高危审批在客户端主体验完成 | Manager 有审批 API | 部分满足 | 桌面客户端要弹窗、轮询/订阅、提交决定 | 客户端 |
|
||||
| 短期凭证和长期密钥隔离 | Manager 使用 `secret_ref` / `lease://` 记录,不传明文 | 基本满足 | Runtime 侧短期凭证派生/注入未验证 | 蜂群 + 基础设施 |
|
||||
| CodeGW 用量归属 | Manager 有模型/余额基础,Runtime payload 带 billing_context | 部分满足 | 子 Agent 调用用量按 task/deployment/role 回流未验证 | 蜂群 + CodeGW + Manager |
|
||||
| 日志和指标 | Manager 有 logs/metrics 接口占位和 runtime state | 部分满足 | CPU/内存/Agent 存活/任务耗时等真实指标来自 Runtime/AKS | 蜂群 + 基础设施 |
|
||||
| 最终交付回流 | artifact/timeline 接收能力已有 | 部分满足 | 最终交付结果、Git branch/commit、部署 URL 需要 Runtime 输出,客户端展示 | 蜂群 + 客户端 |
|
||||
|
||||
## 三、HeiCode-Swarm 项目现状对 Manager 的影响
|
||||
|
||||
从 `HeiCode-Swarm` 项目 README 和代码看,当前项目结构是:
|
||||
|
||||
```text
|
||||
desktop-client -> Orchestrator(FastAPI) -> Redis -> Agent Pods
|
||||
```
|
||||
|
||||
当前 Orchestrator 主要入口:
|
||||
|
||||
| 入口 | 当前作用 | 与 Manager 目标契约差异 |
|
||||
|---|---|---|
|
||||
| `GET /health` | 健康检查 | 可直接用于 Runtime health |
|
||||
| `POST /tasks` | 创建任务 | 不是文档要求的 `POST /api/swarms`,字段也不是 Heicode task/resource/secret/budget 结构 |
|
||||
| `GET /tasks` / `GET /tasks/{id}` | 查询任务 | 可作为早期状态查询,但缺 `swarm_id` 维度 |
|
||||
| `GET /agents` | Agent 列表 | 可映射到 Agent 状态 |
|
||||
| `GET /handoffs` | handoff 历史 | 可映射到 Manager timeline |
|
||||
| `GET /metrics` | Prometheus 指标 | 可映射到 Manager metrics |
|
||||
| `WS /ws/{agent_id}` | Agent 注册、心跳、任务、结果 | Manager 不应直接接 Agent WS,应该由 Runtime 汇总后回调 Manager |
|
||||
|
||||
因此,Manager 后续联调有两种路线:
|
||||
|
||||
| 路线 | 说明 | 风险 |
|
||||
|---|---|---|
|
||||
| 蜂群侧补正式 `POST /api/swarms` | 最符合设计文档,Manager adapter 直接对接 | 需要蜂群项目改接口 |
|
||||
| Manager 临时适配 `POST /tasks` | 可以先跑通现有 Orchestrator | 字段语义不足,无法完整覆盖 resource_grants、secret_ref、approval_policy、budget、artifact callback |
|
||||
|
||||
建议:生产目标仍以 `POST /api/swarms` 为准;短期可以做 `/tasks` 兼容测试,但必须标记为兼容桥接,不作为最终契约。
|
||||
|
||||
## 四、缺失项清单
|
||||
|
||||
### P0:影响蜂群主流程
|
||||
|
||||
| 缺失项 | 当前状态 | 处理建议 |
|
||||
|---|---|---|
|
||||
| 真实 `POST /api/swarms` 联调 | Manager 有 adapter,蜂群当前可见接口是 `/tasks` | 蜂群侧确认是否补 `/api/swarms`;Manager 固定 adapter 文档 |
|
||||
| `deployment_id <-> swarm_id` 真实映射 | Manager 字段已准备,真实值需 Runtime 返回 | Runtime create response 必须返回 `swarm_id` |
|
||||
| task graph / claim / heartbeat 事件 | Manager 可接收/展示 task flow callback;缺真实 Runtime 数据 | 蜂群侧定义并回调 `task.created/claimed/heartbeat/released/completed/failed` |
|
||||
| artifact 真实产出 | Manager 能落库,缺 Runtime 产出 | 蜂群侧回调 `artifact.created`,带 Git branch/commit 或存储 URI |
|
||||
| 审批结果回传 Runtime | Manager adapter 已完成,真实闭环未验证 | 蜂群侧提供接收接口并验证 approved/rejected 后继续或停止 |
|
||||
|
||||
### P1:影响可观测和验收
|
||||
|
||||
| 缺失项 | 当前状态 | 处理建议 |
|
||||
|---|---|---|
|
||||
| handoff/retry/blocked 展示 | Manager 已有 schema 校验、模拟事件和页面任务流展示 | 用真实 Runtime callback 做生产联调验证 |
|
||||
| Runtime 日志/指标 | Manager 有 logs/metrics 位置,缺真实数据源 | 蜂群侧提供日志摘要或查询接口;指标对齐 Prometheus |
|
||||
| 事件来源标识 | API 有部分 runtime/simulated 状态,页面还需强化 | Manager 页面区分 `manager`、`runtime`、`simulated` |
|
||||
| 桌面客户端审批主流程 | Manager 有 API,客户端未完成主体验 | 客户端按 Manager approval API 接入 |
|
||||
| 子 Agent 用量归属 | billing_context 有,真实用量未回流 | Runtime 调模型时带 task/deployment/role correlation |
|
||||
|
||||
### P2:完善项
|
||||
|
||||
| 缺失项 | 当前状态 | 处理建议 |
|
||||
|---|---|---|
|
||||
| 蜂群验收脚本 | 已新增 `scripts/agent_sub_mode_smoke.py`,可检查生产 Manager、Agent health 和指定 deployment timeline | 后续按真实 Runtime deployment 固化执行参数 |
|
||||
| 页面术语统一 | sub/蜂群容易混淆 | 页面和文档统一:sub 是任务组织,swarm 是执行层 |
|
||||
| 兼容 `HeiCode-Swarm` demo client 的说明 | 容易误认为正式 Heicode 桌面客户端 | 文档明确 demo client 不等于 `cc-haha` 正式客户端 |
|
||||
|
||||
## 五、验收口径
|
||||
|
||||
蜂群模式不能只看 Manager 页面有没有数据。必须同时满足:
|
||||
|
||||
1. 桌面客户端能通过 Manager 发起任务,POST 请求体走 V2 加密。
|
||||
2. Manager 创建或桥接 Swarm Run,并保存 `deployment_id`、`runtime_deployment_id`、`swarm_id`。
|
||||
3. Runtime 真实生成任务图,并回传任务状态、Agent 状态、handoff、artifact、审批请求。
|
||||
4. Manager 对 callback 去重、落库、脱敏,并能按 deployment/timeline/artifact 查询。
|
||||
5. 高危审批由客户端展示并提交,Manager 记录,Runtime 收到决定后继续或停止。
|
||||
6. 交付物能回到 Manager 和客户端,不能只停留在 Runtime Redis 或 Agent 日志里。
|
||||
7. 全链路日志和页面不得出现长期明文密钥、模型 key、云 access key、私钥或连接串。
|
||||
|
||||
## 六、下一步建议
|
||||
|
||||
| 顺序 | 动作 | 目标 |
|
||||
|---:|---|---|
|
||||
| 1 | 把 `docs/heicode-manager-sub-swarm-progress-checklist.md` 发给蜂群侧确认 | 让对方知道 Manager 已有什么、需要他们回什么 |
|
||||
| 2 | 确认蜂群侧最终入口是 `/api/swarms` 还是先兼容 `/tasks` | 避免双方接口错位 |
|
||||
| 3 | 用 Manager callback 接口跑一次蜂群侧真实 `artifact.created` / `approval.requested` | 证明回调、artifact、approval、timeline 有真实数据 |
|
||||
| 4 | 客户端按 `docs/integration/heicode-desktop-sub-agile-api.md` 接入审批和 timeline | 跑通用户主体验 |
|
||||
| 5 | 根据真实联调结果更新本文状态 | 把缺失项从“缺接口/未联调”改为“已验证/阻塞/延期” |
|
||||
@@ -0,0 +1,677 @@
|
||||
# Agent Manager 蜂群 Runtime 接口实现要求
|
||||
|
||||
更新时间:2026-05-28
|
||||
面向对象:Agent Manager / HeiCode-Swarm Runtime 开发负责人
|
||||
用途:Agent Manager 按本文实现接口、字段和回调后,Heicode Manager 可直接联调蜂群模式。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
Heicode Manager 已负责控制面和记录面:
|
||||
|
||||
- 生成 deployment / swarm 创建请求。
|
||||
- 传递 `resource_grants`、`secret_ref`、预算、模型网关上下文和 callback 地址。
|
||||
- 保存 `deployment_id <-> runtime_deployment_id <-> swarm_id` 映射。
|
||||
- 接收 Runtime callback,落库 artifact / approval / timeline / audit。
|
||||
- 用户 approve/reject 后,把审批决定回传 Runtime。
|
||||
|
||||
Agent Manager / Swarm Runtime 需要负责执行层:
|
||||
|
||||
- 创建真实 Swarm Run。
|
||||
- 生成真实 task graph。
|
||||
- 管理 Agent claim / heartbeat / handoff / retry / blocked / failed 状态机。
|
||||
- 真实执行任务并产出 artifact。
|
||||
- 在高危动作前暂停并回调审批请求。
|
||||
- 接收审批结果后继续或停止。
|
||||
- 回传日志、指标、用量和最终结果。
|
||||
|
||||
### 1.1 2026-05-28 生产联调实测结论
|
||||
|
||||
使用测试用户 `zsbgnw@gmail.com`(Manager 用户 `chenchen`,`user_id=22`)在生产 Manager 上实测蜂群入口。
|
||||
|
||||
当前生产 Manager Runtime 配置实际为:
|
||||
|
||||
```text
|
||||
AGENT_RUNTIME_BASE_URL=http://20.212.121.126
|
||||
AGENT_RUNTIME_CREATE_PATH=/api/agent/deployments
|
||||
AGENT_RUNTIME_STOP_PATH=/api/agent/deployments/{deployment_id}/stop
|
||||
```
|
||||
|
||||
实测结果:
|
||||
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `GET /api/agent/runtime/health` | 通过,远端 Agent Manager IP healthy |
|
||||
| `POST /api/swarms` 使用 `agent_runtime.platform=agent-swarm` | 失败,Manager 返回 `agent_runtime.platform must be agent when runtime context is present` |
|
||||
| `POST /api/swarms` 使用 `agent_runtime.platform=agent` | 通过 |
|
||||
| Manager deployment | `dep_72cdc138b9c8` |
|
||||
| Manager 返回 swarm_id | `dep_72cdc138b9c8`,当前是 Manager 本地 deployment id |
|
||||
| Runtime deployment | `dep_77370f27186f` |
|
||||
| Manager events | `deployment.accepted`、`runtime.sync.started`、`runtime.sync.accepted` |
|
||||
| Manager stop | 通过 |
|
||||
| Runtime stop | 通过,Agent Manager 侧 `dep_77370f27186f` 状态为 `stopped` |
|
||||
| Runtime callback | 未观察到,生产 Manager callback 表 0 条对应记录 |
|
||||
| artifact / timeline callback | 未产出 |
|
||||
| SK / usage callback | 未产出 |
|
||||
| Runtime logs | 仅 `Pod ... has no logs yet` |
|
||||
| Runtime metrics | 返回固定 CPU/内存/网络汇总,看起来仍是占位值 |
|
||||
|
||||
结论:
|
||||
|
||||
1. Heicode Manager 的 `/api/swarms` adapter 能接收用户态蜂群请求,并能通过当前 Runtime bridge 调 Agent Manager IP。
|
||||
2. 当前生产链路实际走的是 Agent Manager `/api/agent/deployments`,不是 Agent Manager `/api/swarms`。
|
||||
3. Manager stop 可以传递到 Agent Manager deployment stop。
|
||||
4. 当前还没有真实蜂群 Runtime callback、task graph、artifact、SK、usage。
|
||||
5. Agent Manager 如果要满足正式蜂群模式,仍需实现本文后续定义的 `/api/swarms`、`/api/swarms/{swarm_id}`、`/api/swarms/{swarm_id}/stop`、callback 和 usage 约束。
|
||||
|
||||
## 2. 总体调用链
|
||||
|
||||
```text
|
||||
Heicode Desktop Client
|
||||
-> Heicode Manager
|
||||
POST /api/swarms 或 /api/agent/user/deployments
|
||||
-> Agent Manager / Swarm Runtime
|
||||
POST /api/swarms
|
||||
<- Runtime response
|
||||
runtime_deployment_id / swarm_id / status
|
||||
<- Runtime callback
|
||||
POST /api/agent/callbacks/swarm-events
|
||||
-> Runtime approval decision
|
||||
POST /api/swarms/{swarm_id}/approvals/{approval_id}
|
||||
```
|
||||
|
||||
客户端到 Heicode Manager 的 `POST /api/swarms` 与普通 sub 用户态接口一样,必须支持 V2 body 加密:
|
||||
|
||||
```http
|
||||
Content-Encoding: heicode-aead-v1
|
||||
X-Heicode-Device-Id: <device_id>
|
||||
X-Heicode-Timestamp: <unix_ms>
|
||||
X-Heicode-Nonce: <nonce_hex>
|
||||
X-Heicode-Fingerprint: <device_fingerprint>
|
||||
X-Heicode-Eph-Pubkey: <base64_x25519_ephemeral_pubkey>
|
||||
X-Heicode-Signature: <base64_ed25519_signature>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
未加密 Web 控制台请求仍按 Manager session cookie + `New-Api-User` 兼容处理。Manager 调 Agent Manager / Swarm Runtime 的服务间请求不使用该客户端 body 加密协议,而使用 `Authorization: Bearer <service_token>` 和公共追踪 Header。
|
||||
|
||||
## 3. Agent Manager 必须提供的接口
|
||||
|
||||
| 优先级 | 方法 | 路径 | 必须 | 用途 |
|
||||
|---|---|---|---:|---|
|
||||
| P0 | `GET` | `/api/agent/health` | 是 | 健康检查 |
|
||||
| P0 | `POST` | `/api/swarms` | 是 | 创建真实 Swarm Run |
|
||||
| P0 | `POST` | `/api/swarms/{swarm_id}/stop` | 是 | 停止 Swarm Run |
|
||||
| P0 | `POST` | `/api/swarms/{swarm_id}/approvals/{approval_id}` | 是 | 接收 Manager 审批决定 |
|
||||
| P0 | callback | Manager `/api/agent/callbacks/swarm-events` | 是 | 回写状态、task、handoff、artifact、approval |
|
||||
| P1 | `GET` | `/api/swarms/{swarm_id}` | 建议 | 查询 Runtime 详情 |
|
||||
| P1 | `GET` | `/api/swarms/{swarm_id}/tasks` | 建议 | 查询 task graph |
|
||||
| P1 | `GET` | `/api/swarms/{swarm_id}/logs` | 建议 | 查询日志 |
|
||||
| P1 | `GET` | `/api/swarms/{swarm_id}/metrics` | 建议 | 查询指标 |
|
||||
|
||||
如果短期无法提供 `/api/swarms`,可以临时确认兼容路径,例如 `/tasks`。但这只能作为联调过渡,不作为最终生产契约。
|
||||
|
||||
## 4. 认证与公共 Header
|
||||
|
||||
Manager 调 Agent Manager 时携带:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <service_token>
|
||||
X-User-ID: <manager_user_id>
|
||||
X-Binding-Scope: <binding_scope>
|
||||
X-Correlation-ID: <correlation_id>
|
||||
X-Idempotency-Key: manager-<manager_deployment_id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. `Authorization` 用于服务间鉴权。
|
||||
2. `X-Correlation-ID` 必须贯穿 create、callback、approval decision、logs、metrics。
|
||||
3. `X-Idempotency-Key` 必须支持幂等;重复创建请求不能生成多个真实 Swarm Run。
|
||||
4. Agent Manager 不得要求 Manager 传长期明文密钥。
|
||||
|
||||
## 5. `GET /api/agent/health`
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "healthy",
|
||||
"service": "agent-manager-swarm-runtime",
|
||||
"version": "1.0.0",
|
||||
"runtime": "aks",
|
||||
"time": "2026-05-28T10:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
验收:
|
||||
|
||||
- HTTP 200。
|
||||
- `status` 为 `healthy` / `ok` / `up` 之一。
|
||||
- 不返回密钥、Token、连接串。
|
||||
|
||||
## 6. `POST /api/swarms`
|
||||
|
||||
### 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"orchestration_plan": {
|
||||
"intent_id": "task_123",
|
||||
"template_hint": "heicode-task",
|
||||
"objective": "完成本轮用户目标",
|
||||
"sub_mode": "agile",
|
||||
"risk_level": "medium",
|
||||
"budget": {
|
||||
"max_tokens": 120000,
|
||||
"max_cost_usd": 8,
|
||||
"max_duration_sec": 3600
|
||||
},
|
||||
"user_context": {
|
||||
"user_id": "22",
|
||||
"channel_id": "heicode",
|
||||
"binding_scope": "task-task-123"
|
||||
},
|
||||
"billing_context": {
|
||||
"provider": "newapi",
|
||||
"default_model_id": "model_xxx",
|
||||
"allowed_model_ids": ["model_xxx"],
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key"
|
||||
},
|
||||
"agile_context": {
|
||||
"iteration": "2026-05-28",
|
||||
"stage": "development",
|
||||
"checkpoint": "draft_created",
|
||||
"acceptance_criteria": [
|
||||
"接口返回成功",
|
||||
"artifact 可回写到 timeline",
|
||||
"不出现明文密钥"
|
||||
],
|
||||
"next_action": "continue",
|
||||
"requires_user_approval": false
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"role_template": "backend",
|
||||
"goal": "完成后端实现和测试",
|
||||
"default_model_id": "model_xxx",
|
||||
"resource_grants": []
|
||||
}
|
||||
],
|
||||
"resource_grants": []
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"role": "backend",
|
||||
"resource_grants": []
|
||||
}
|
||||
],
|
||||
"resource_grants": [
|
||||
{
|
||||
"grant_id": "grant-task-123-git",
|
||||
"resource_id": "repo-main",
|
||||
"resource_type": "git",
|
||||
"permission_scope": ["repo:read", "repo:write:feature-branches"],
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
|
||||
"ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
|
||||
"target_role": "backend",
|
||||
"constraints": {
|
||||
"allowed_paths": "heicode/**"
|
||||
},
|
||||
"metadata": {
|
||||
"repo": "heicode-manager"
|
||||
}
|
||||
}
|
||||
],
|
||||
"callback": {
|
||||
"url": "https://code.xinghanlab.com/api/agent/callbacks/swarm-events",
|
||||
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agent-callback-signing-key",
|
||||
"subscribed_events": [
|
||||
"deployment.status_changed",
|
||||
"task.created",
|
||||
"task.claimed",
|
||||
"task.heartbeat",
|
||||
"task.blocked",
|
||||
"task.retried",
|
||||
"task.failed",
|
||||
"task.completed",
|
||||
"handoff.requested",
|
||||
"handoff.completed",
|
||||
"approval.requested",
|
||||
"artifact.created",
|
||||
"timeline.updated",
|
||||
"budget.alert"
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"manager_deployment_id": "dep_xxx",
|
||||
"heicode_deployment_id": "dep_xxx",
|
||||
"heicode_runtime_bridge": true,
|
||||
"correlation_id": "corr_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Agent Manager 必须消费的字段
|
||||
|
||||
| 字段 | 必须 | 说明 |
|
||||
|---|---:|---|
|
||||
| `orchestration_plan.objective` | 是 | 用户目标 |
|
||||
| `orchestration_plan.sub_mode` | 是 | `agile` / `waterfall`;蜂群执行时也要保留此组织方式 |
|
||||
| `orchestration_plan.risk_level` | 是 | 高危动作必须走审批 |
|
||||
| `orchestration_plan.budget` | 是 | token / cost / duration 上限 |
|
||||
| `billing_context.provider` | 是 | 当前为 `newapi` |
|
||||
| `billing_context.secret_ref` | 是 | 模型网关密钥引用,只能是 `azkv://...` |
|
||||
| `agents[].role` 或 `role_template` | 是 | 子 Agent 角色 |
|
||||
| `resource_grants[]` | 是 | 资源授权清单 |
|
||||
| `resource_grants[].secret_ref` | 凭据资源必填 | 只允许 Key Vault 引用,不允许明文 |
|
||||
| `callback.url` | 是 | Runtime 回调 Manager 的地址 |
|
||||
| `callback.signing_secret_ref` | 建议 | HMAC 签名密钥引用 |
|
||||
| `metadata.manager_deployment_id` | 是 | Manager 侧 deployment id |
|
||||
| `metadata.correlation_id` | 是 | 全链路追踪 |
|
||||
|
||||
### 响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deployment_id": "runtime-dep-123",
|
||||
"swarm_id": "swarm-123",
|
||||
"status": "running",
|
||||
"created_at": "2026-05-28T10:00:00Z",
|
||||
"estimated_ready_at": "2026-05-28T10:02:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
兼容响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"deployment_id": "runtime-dep-123",
|
||||
"swarm_id": "swarm-123",
|
||||
"status": "running"
|
||||
}
|
||||
```
|
||||
|
||||
Manager 映射规则:
|
||||
|
||||
| Agent Manager 返回字段 | Manager 保存字段 |
|
||||
|---|---|
|
||||
| `data.deployment_id` / `deployment_id` / `id` | `runtime_deployment_id` |
|
||||
| `data.swarm_id` / `swarm_id` / `runtime_swarm_id` | `runtime_swarm_id` |
|
||||
| `data.status` / `status` / `runtime_status` | `runtime_state` |
|
||||
|
||||
## 7. Runtime 必须生成的 task graph
|
||||
|
||||
Agent Manager 创建 Swarm Run 后,必须在 Runtime 内部生成任务图,并通过 callback 回写。
|
||||
|
||||
每个 task 至少包含:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "task-backend-1",
|
||||
"title": "实现后端接口",
|
||||
"description": "完成 API、校验和测试",
|
||||
"agent_role": "backend",
|
||||
"status": "pending",
|
||||
"depends_on": ["task-design-1"],
|
||||
"attempt": 1
|
||||
}
|
||||
```
|
||||
|
||||
状态建议:
|
||||
|
||||
| status | 说明 |
|
||||
|---|---|
|
||||
| `pending` | 等待执行 |
|
||||
| `claimed` | 已被 Agent 领取 |
|
||||
| `running` | 执行中 |
|
||||
| `blocked` | 阻塞 |
|
||||
| `handoff_requested` | 等待交接 |
|
||||
| `retrying` | 重试中 |
|
||||
| `completed` | 完成 |
|
||||
| `failed` | 失败 |
|
||||
|
||||
## 8. Runtime 回调 Manager
|
||||
|
||||
Manager 回调地址:
|
||||
|
||||
```http
|
||||
POST https://code.xinghanlab.com/api/agent/callbacks/swarm-events
|
||||
X-Agent-Service-Token: <callback_token>
|
||||
X-Agent-Event-Id: <event_id>
|
||||
X-Correlation-ID: <correlation_id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
也支持 HMAC:
|
||||
|
||||
```http
|
||||
X-Agent-Timestamp: <unix_ms>
|
||||
X-Agent-Signature: sha256=<hex>
|
||||
```
|
||||
|
||||
HMAC 签名内容:
|
||||
|
||||
```text
|
||||
timestamp + "." + event_id + "." + raw_body
|
||||
```
|
||||
|
||||
联调前可拉取 Manager 当前接受的事件 schema:
|
||||
|
||||
```http
|
||||
GET https://code.xinghanlab.com/api/agent/callbacks/swarm-events/schema
|
||||
```
|
||||
|
||||
### 通用 callback envelope
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_123",
|
||||
"idempotency_key": "evt_123",
|
||||
"event_type": "task.claimed",
|
||||
"deployment_id": "dep_xxx",
|
||||
"swarm_id": "swarm-123",
|
||||
"agent_instance_id": "agent-backend-1",
|
||||
"task_id": "task-backend-1",
|
||||
"occurred_at": "2026-05-28T10:10:00Z",
|
||||
"correlation_id": "corr_xxx",
|
||||
"source": "agent-manager-runtime",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
要求:
|
||||
|
||||
1. `event_id` 全局唯一。
|
||||
2. 重试同一事件必须复用相同 `event_id` 或 `idempotency_key`。
|
||||
3. `deployment_id` 优先使用 Manager deployment id。
|
||||
4. 如果只知道 `swarm_id`,Manager 也会按 `runtime_swarm_id` 查找 deployment。
|
||||
5. `source` 要能区分真实 Runtime,例如 `agent-manager-runtime`,不要写 `simulated`。
|
||||
|
||||
## 9. 必须回调的事件和字段
|
||||
|
||||
| event_type | 必填字段 | 说明 |
|
||||
|---|---|---|
|
||||
| `deployment.status_changed` | `payload.status` | Runtime 整体状态变化 |
|
||||
| `task.created` | `task_id`, `payload.title` | 任务图新增任务 |
|
||||
| `task.claimed` | `task_id`, `payload.agent_role` | Agent 领取任务 |
|
||||
| `task.running` | `task_id`, `payload.agent_role` | Agent 开始执行 |
|
||||
| `task.heartbeat` | `task_id`, `payload.agent_role` | Agent 心跳 |
|
||||
| `task.blocked` | `task_id`, `payload.reason` | 任务阻塞 |
|
||||
| `task.retried` | `task_id`, `payload.attempt` | 任务重试 |
|
||||
| `task.failed` | `task_id`, `payload.reason` | 任务失败 |
|
||||
| `task.completed` | `task_id` | 任务完成 |
|
||||
| `handoff.requested` | `task_id`, `payload.from_role`, `payload.to_role` | 请求交接 |
|
||||
| `handoff.completed` | `task_id`, `payload.from_role`, `payload.to_role` | 完成交接 |
|
||||
| `artifact.created` | `artifact.artifact_id` 或 `payload.artifact_id` | 产物生成 |
|
||||
| `approval.requested` | `payload.approval_id`, `payload.operation`, `payload.risk_level` | 高危审批 |
|
||||
| `timeline.updated` | `payload.title` | 用户可见时间线 |
|
||||
| `budget.alert` | `payload.threshold_pct` | 预算告警 |
|
||||
|
||||
## 10. artifact.created
|
||||
|
||||
Runtime 产生中间产物或最终交付物时回调:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_artifact_1",
|
||||
"event_type": "artifact.created",
|
||||
"deployment_id": "dep_xxx",
|
||||
"swarm_id": "swarm-123",
|
||||
"task_id": "task-backend-1",
|
||||
"source": "agent-manager-runtime",
|
||||
"artifact": {
|
||||
"artifact_id": "art_backend_patch_1",
|
||||
"artifact_type": "code_patch",
|
||||
"title": "Backend API patch",
|
||||
"summary": "新增任务创建和状态查询接口",
|
||||
"uri": "git://heicode-manager/branches/feature/task-api",
|
||||
"checksum": "sha256:abc123",
|
||||
"metadata": {
|
||||
"agent_role": "backend",
|
||||
"redacted": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
支持的 `artifact_type`:
|
||||
|
||||
| 类型 | 说明 |
|
||||
|---|---|
|
||||
| `code_patch` | 代码补丁或分支 |
|
||||
| `document` | 文档 |
|
||||
| `test_report` | 测试报告 |
|
||||
| `deployment_manifest` | 部署清单 |
|
||||
| `log_bundle` | 日志包 |
|
||||
| `other` | 其他 |
|
||||
|
||||
要求:
|
||||
|
||||
1. 大文件不要内联到 callback body。
|
||||
2. `summary` 可展示给用户,不得包含密钥。
|
||||
3. `uri` 可以是 `git://`、`artifact://`、`azblob://`、`https://`。
|
||||
4. metadata 必须脱敏。
|
||||
|
||||
## 11. approval.requested 与审批结果
|
||||
|
||||
高危动作前 Runtime 必须暂停,并回调:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_approval_1",
|
||||
"event_type": "approval.requested",
|
||||
"deployment_id": "dep_xxx",
|
||||
"swarm_id": "swarm-123",
|
||||
"task_id": "task-deploy-1",
|
||||
"source": "agent-manager-runtime",
|
||||
"payload": {
|
||||
"approval_id": "runtime-approval-1",
|
||||
"operation": "deploy.production",
|
||||
"resource_id": "prod-env",
|
||||
"resource_type": "azure",
|
||||
"resource_scope": "resource-group/heicode-prod",
|
||||
"target_role": "ops",
|
||||
"risk_level": "high",
|
||||
"requires_credential": true,
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/prod-deploy",
|
||||
"ttl_seconds": 600,
|
||||
"reason": "需要部署到生产环境"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Manager / 客户端审批后,Manager 调 Runtime:
|
||||
|
||||
```http
|
||||
POST /api/swarms/{swarm_id}/approvals/{approval_id}
|
||||
Authorization: Bearer <service_token>
|
||||
X-Correlation-ID: <correlation_id>
|
||||
X-Idempotency-Key: approval-decision-<approval_id>-<decision>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"approval_id": "runtime-approval-1",
|
||||
"decision": "approved",
|
||||
"reason": "用户已确认",
|
||||
"manager_deployment_id": "dep_xxx",
|
||||
"swarm_id": "swarm-123",
|
||||
"decided_by": "user:22",
|
||||
"decided_at": "2026-05-28T10:20:00Z",
|
||||
"credential_lease": {
|
||||
"lease_id": "lease_xxx",
|
||||
"credential_ref": "lease://runtime/runtime-approval-1",
|
||||
"expires_at": "2026-05-28T10:30:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`decision` 枚举:
|
||||
|
||||
| decision | Runtime 行为 |
|
||||
|---|---|
|
||||
| `approved` | 继续原高危动作 |
|
||||
| `rejected` | 停止该动作,回调 `timeline.updated` 或 `task.failed` |
|
||||
|
||||
要求:
|
||||
|
||||
1. Runtime 收到 `approved` 后才可以继续高危动作。
|
||||
2. Runtime 收到 `rejected` 后不能继续执行该动作。
|
||||
3. Runtime 不得把 `credential_ref` 展开写入日志或 artifact metadata。
|
||||
4. Runtime 必须把审批结果后的状态继续通过 callback 回写。
|
||||
|
||||
## 12. 日志、指标和用量
|
||||
|
||||
### 日志
|
||||
|
||||
建议提供:
|
||||
|
||||
```http
|
||||
GET /api/swarms/{swarm_id}/logs?limit=100&cursor=<cursor>
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"timestamp": "2026-05-28T10:21:00Z",
|
||||
"level": "info",
|
||||
"agent_role": "backend",
|
||||
"task_id": "task-backend-1",
|
||||
"message": "test completed",
|
||||
"redacted": true
|
||||
}
|
||||
],
|
||||
"next_cursor": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 指标
|
||||
|
||||
建议提供:
|
||||
|
||||
```http
|
||||
GET /api/swarms/{swarm_id}/metrics?window=15m&step=60s
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"swarm_id": "swarm-123",
|
||||
"agent_metrics": [
|
||||
{
|
||||
"agent_instance_id": "agent-backend-1",
|
||||
"agent_role": "backend",
|
||||
"task_id": "task-backend-1",
|
||||
"status": "running",
|
||||
"cpu_percent": 12.5,
|
||||
"memory_bytes": 268435456,
|
||||
"uptime_seconds": 300
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 用量
|
||||
|
||||
Runtime 调模型时必须带关联字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `manager_deployment_id` | Manager deployment id |
|
||||
| `swarm_id` | Runtime swarm id |
|
||||
| `task_id` | 当前任务 |
|
||||
| `agent_role` | Agent 角色 |
|
||||
| `model_id` | 使用模型 |
|
||||
| `correlation_id` | 全链路追踪 |
|
||||
|
||||
Runtime 可通过 `budget.alert` 或 `timeline.updated` 回传用量摘要。
|
||||
|
||||
## 13. 安全约束
|
||||
|
||||
必须遵守:
|
||||
|
||||
1. 禁止在请求、callback、日志、artifact metadata 中出现明文密码、Token、私钥、连接串、云 access key、模型 key。
|
||||
2. 长期凭据只能通过 `azkv://<vault>/secrets/<name>` 传引用。
|
||||
3. Runtime 可以把短期租约写成 `lease://...`,但不能写真实密钥。
|
||||
4. `secret_ref`、`credential_ref` 只能作为引用使用,不能在用户可见内容中展开。
|
||||
5. 所有 callback payload 必须脱敏。
|
||||
6. 高危动作必须先 `approval.requested`,不能先执行后补审批。
|
||||
|
||||
## 14. 错误码建议
|
||||
|
||||
| HTTP | code | 场景 |
|
||||
|---:|---|---|
|
||||
| 401 | `UNAUTHORIZED` | service token 无效 |
|
||||
| 403 | `FORBIDDEN` | 权限不足 |
|
||||
| 404 | `SWARM_NOT_FOUND` | swarm 不存在 |
|
||||
| 409 | `IDEMPOTENCY_CONFLICT` | 幂等键冲突 |
|
||||
| 422 | `VALIDATION_ERROR` | 请求字段缺失 |
|
||||
| 422 | `SECRET_REF_INVALID` | `secret_ref` 非 `azkv://...` |
|
||||
| 422 | `POLICY_REJECTED` | 高危策略拒绝 |
|
||||
| 500 | `INTERNAL_ERROR` | Runtime 内部错误 |
|
||||
|
||||
错误响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "resource_grants[0].secret_ref is required",
|
||||
"request_id": "corr_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 15. 联调验收清单
|
||||
|
||||
| 步骤 | 操作 | 通过标准 |
|
||||
|---:|---|---|
|
||||
| 1 | Manager 调 `GET /api/agent/health` | 返回 healthy |
|
||||
| 2 | Manager 调 `POST /api/swarms` | 返回真实 `deployment_id` 和 `swarm_id` |
|
||||
| 3 | Runtime 回调 `deployment.status_changed` | Manager timeline 可见 |
|
||||
| 4 | Runtime 回调 `task.created` | Manager task flow / Agent task map 可见 |
|
||||
| 5 | Runtime 回调 `task.claimed` / `task.heartbeat` | Manager 可看到 Agent 领取和心跳 |
|
||||
| 6 | Runtime 回调 `handoff.requested` / `handoff.completed` | Manager 可看到交接 |
|
||||
| 7 | Runtime 回调 `artifact.created` | Manager artifact 列表可见 |
|
||||
| 8 | Runtime 回调 `approval.requested` | Manager approval 列表出现 pending |
|
||||
| 9 | 客户端 / Manager approve | Runtime 收到 decision 并继续 |
|
||||
| 10 | 客户端 / Manager reject | Runtime 收到 decision 并停止对应动作 |
|
||||
| 11 | Runtime 回传 logs / metrics / usage | Manager 可查询或 timeline 可见 |
|
||||
| 12 | Runtime 回调最终 `deployment.status_changed=completed` | Manager 和客户端看到完成 |
|
||||
|
||||
## 16. 当前不能误报完成的项
|
||||
|
||||
以下项只有在 Agent Manager 真实实现并联调后才能算完成:
|
||||
|
||||
1. 真实 Swarm Run 创建成功。
|
||||
2. 真实 task graph 生成。
|
||||
3. 真实 Agent claim / heartbeat。
|
||||
4. 真实 handoff / retry / blocked。
|
||||
5. 真实 artifact 产出。
|
||||
6. 审批后 Runtime 继续或停止。
|
||||
7. Runtime 真实日志和指标。
|
||||
8. 子 Agent 模型用量归属。
|
||||
9. 最终交付物回到客户端。
|
||||
|
||||
Manager 本地模拟事件、测试桩、空态页面和 schema 校验只能证明 Manager 接收和展示能力,不能证明蜂群生产闭环完成。
|
||||
@@ -0,0 +1,491 @@
|
||||
# Agent Manager 蜂群 Runtime 联调待确认与补充要求
|
||||
|
||||
更新时间:2026-05-29
|
||||
发给:Agent Manager / HeiCode-Swarm Runtime 负责人
|
||||
范围:仅针对蜂群模式,不包含普通 sub 敏捷模式。
|
||||
|
||||
## 1. 先明确边界
|
||||
|
||||
普通 sub 模式和蜂群模式是两套不同的运行时部署,不能混用。
|
||||
|
||||
| 模式 | Runtime | 当前已知地址 | 说明 |
|
||||
|---|---|---|---|
|
||||
| 普通 sub 敏捷模式 | Agent Manager / Agent Runtime | `http://20.212.121.126` | 用于普通子 Agent 敏捷开发流程 |
|
||||
| 蜂群模式 | HeiCode-Swarm Orchestrator | `http://52.139.240.116:8000` | 用于蜂群任务图、Agent 协作、handoff、task graph |
|
||||
|
||||
注意:两套服务可能都提供 `/api/swarms` 这类路径,但业务含义不同。Heicode Manager 后续需要按模式分别配置,不应只用一套 `AGENT_RUNTIME_BASE_URL` 混跑。
|
||||
|
||||
## 2. 本次读取到的蜂群 Runtime 新能力
|
||||
|
||||
根据 HeiCode-Swarm 最新 `蜂群对接文档.md` 和代码,蜂群 Runtime 已经补充以下接口:
|
||||
|
||||
| 能力 | 路径 | 当前判断 |
|
||||
|---|---|---|
|
||||
| 健康检查 | `GET /api/agent/health` | 已提供 |
|
||||
| 创建蜂群 run | `POST /api/swarms` | 已提供 |
|
||||
| 兼容创建入口 | `POST /api/agent/deployments` | 已提供 |
|
||||
| 查询 swarm 详情 | `GET /api/swarms/{swarm_id}` | 已提供 |
|
||||
| 查询任务图 | `GET /api/swarms/{swarm_id}/tasks` | 已提供 |
|
||||
| 查询日志 | `GET /api/swarms/{swarm_id}/logs` | 已提供 |
|
||||
| 查询指标 | `GET /api/swarms/{swarm_id}/metrics` | 已提供 |
|
||||
| 停止 swarm | `POST /api/swarms/{swarm_id}/stop` | 已提供 |
|
||||
| 审批结果回传 | `POST /api/swarms/{swarm_id}/approvals/{approval_id}` | 已提供 |
|
||||
| callback token / HMAC | Runtime callback Manager | 代码支持 |
|
||||
| `azkv://` 校验 | 请求体 secret 引用 | 代码支持 |
|
||||
| 幂等创建 | `X-Idempotency-Key` | 代码支持 |
|
||||
|
||||
本地契约测试也已确认通过:
|
||||
|
||||
```text
|
||||
python3 scripts/test-runtime-contract.py
|
||||
runtime contract checks passed
|
||||
```
|
||||
|
||||
## 3. 实测结果
|
||||
|
||||
### 3.1 健康检查通过
|
||||
|
||||
请求:
|
||||
|
||||
```bash
|
||||
curl http://52.139.240.116:8000/api/agent/health
|
||||
```
|
||||
|
||||
返回核心内容:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "healthy",
|
||||
"service": "heicode-swarm-runtime",
|
||||
"version": "1.0.0",
|
||||
"runtime": "aks",
|
||||
"capabilities": [
|
||||
"swarm.create",
|
||||
"task.flow",
|
||||
"handoff.events",
|
||||
"artifact.events",
|
||||
"approval.pause_resume",
|
||||
"deployment.stop",
|
||||
"runtime.tasks.query",
|
||||
"runtime.logs.query",
|
||||
"runtime.metrics.query"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 创建接口鉴权已通过
|
||||
|
||||
Agent Manager / 蜂群 Runtime 已提供当前部署认可的 Runtime Bearer token。使用该 token 后,`POST /api/swarms` 不再返回 401。
|
||||
|
||||
```bash
|
||||
curl -X POST http://52.139.240.116:8000/api/swarms \
|
||||
-H "Authorization: Bearer <runtime-service-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Correlation-Id: corr_token_check_local" \
|
||||
-H "X-Idempotency-Key: codex-swarm-token-check" \
|
||||
-d '<valid-swarm-create-body>'
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deployment_id": "runtime-dep-e9ef617146fd",
|
||||
"runtime_deployment_id": "runtime-dep-e9ef617146fd",
|
||||
"manager_deployment_id": "dep_token_check_local",
|
||||
"swarm_id": "swarm-64004a6aaf67",
|
||||
"status": "running",
|
||||
"created": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
结论:Runtime token 阻塞已解决。具体 token 不写入 Markdown 或 Git。
|
||||
|
||||
### 3.3 查询和停止接口已通过
|
||||
|
||||
对 `swarm-64004a6aaf67` 查询结果:
|
||||
|
||||
| 接口 | 结果 |
|
||||
|---|---|
|
||||
| `GET /api/swarms/{swarm_id}` | 成功,返回 Runtime run 详情 |
|
||||
| `GET /api/swarms/{swarm_id}/tasks` | 成功 |
|
||||
| `GET /api/swarms/{swarm_id}/logs` | 成功,返回 Runtime event 列表 |
|
||||
| `GET /api/swarms/{swarm_id}/metrics` | 成功,返回任务数、运行时长、Agent 数等轻量指标 |
|
||||
| `POST /api/swarms/{swarm_id}/stop` | 成功,状态变为 `stopped` |
|
||||
|
||||
### 3.4 新发现:线上蜂群 Runtime 仍只生成单任务
|
||||
|
||||
2026-05-29 已按 `HeiCode-Swarm/蜂群对接文档.md` 的结构真实请求线上蜂群 Runtime:
|
||||
|
||||
```text
|
||||
POST http://52.139.240.116:8000/api/swarms
|
||||
Authorization: Bearer <runtime-service-token>
|
||||
```
|
||||
|
||||
第一组使用文档中的单 Agent 结构,`agents` 放在 `orchestration_plan.agents`,角色为 `backend`。创建成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"runtime_deployment_id": "runtime-dep-fc99510eb2c5",
|
||||
"manager_deployment_id": "dep_doc_single",
|
||||
"swarm_id": "swarm-004f1ae3a114",
|
||||
"status": "running"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
但查询任务后,线上 Runtime 没有保留文档请求中的 `backend` 角色,而是生成 `general` 单任务:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "swarm-004f1ae3a114-task-1",
|
||||
"title": "Swarm objective",
|
||||
"agent_role": "general",
|
||||
"context": {
|
||||
"workflow_mode": "single_agent",
|
||||
"allow_handoff": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
第二组使用同样文档结构,但传入 3 个 agents:`planner`、`builder`、`reviewer`。创建成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"runtime_deployment_id": "runtime-dep-ba9de6fe1df3",
|
||||
"manager_deployment_id": "dep_doc_multi",
|
||||
"swarm_id": "swarm-ee319f1d4705",
|
||||
"status": "running"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
但查询任务后仍只生成 1 个 `general` 任务:
|
||||
|
||||
```json
|
||||
{
|
||||
"tasks": [
|
||||
{
|
||||
"title": "Swarm objective",
|
||||
"agent_role": "general",
|
||||
"status": "pending",
|
||||
"task_graph_id": "task-1",
|
||||
"context": {
|
||||
"workflow_mode": "single_agent",
|
||||
"allow_handoff": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
两组测试的日志均只出现 `Created 1 task(s)`,停止接口均返回 `status=stopped`。
|
||||
|
||||
这与蜂群设计要求的“动态任务图、能力编队、handoff、协作任务链”仍有差距。当前线上 Runtime 看起来仍是 single-agent fallback 路径,尚未按请求中的 3 个 planner/builder/reviewer 任务生成真实 task graph。
|
||||
|
||||
### 3.5 Gitee main 与线上 Runtime 行为不一致
|
||||
|
||||
已检查项目地址:
|
||||
|
||||
```text
|
||||
http://gitee.ath.cx:3000/taijibaga/HeiCode-Swarm
|
||||
```
|
||||
|
||||
当前 main commit:
|
||||
|
||||
```text
|
||||
802bbe97ca278d2da3c70a3e471219db62c01c98 对接文档
|
||||
```
|
||||
|
||||
仓库 main 中 `orchestrator/swarm_runtime.py` 的 `build_task_descriptions()` 逻辑会读取:
|
||||
|
||||
```python
|
||||
agents = plan.get("agents") or body.get("agents") or []
|
||||
```
|
||||
|
||||
如果请求里传入 3 个 agents,本地按同样 payload 测试该函数,结果会生成 3 个任务:
|
||||
|
||||
```text
|
||||
plan-1 planner
|
||||
build-1 builder
|
||||
verify-1 reviewer
|
||||
count=3
|
||||
```
|
||||
|
||||
但线上 `http://52.139.240.116:8000` 对同样结构只生成:
|
||||
|
||||
```text
|
||||
task-1 general
|
||||
workflow_mode=single_agent
|
||||
allow_handoff=false
|
||||
```
|
||||
|
||||
另外,线上返回中的 `workflow_mode`、`allow_handoff`、`required_capabilities`、`source=runtime_bridge` 等字段,在当前 Gitee main 的 `orchestrator/swarm_runtime.py` 中没有对应代码。
|
||||
|
||||
因此当前判断是:
|
||||
|
||||
1. 线上 `52.139.240.116:8000` 运行的镜像/代码不是 Gitee main 当前代码;或
|
||||
2. 线上部署使用了未提交到 Gitee main 的分支/镜像;或
|
||||
3. 线上通过环境变量或另一套 runtime bridge 强制走 single-agent fallback。
|
||||
|
||||
请 Agent Manager / HeiCode-Swarm 负责人确认线上部署的镜像 tag、代码 commit、分支,以及是否启用了 single-agent fallback 配置。
|
||||
|
||||
## 4. Agent Manager / 蜂群 Runtime 需要提供或确认
|
||||
|
||||
### P0 必须先提供
|
||||
|
||||
| 项 | 需要提供 / 确认 | 原因 |
|
||||
|---|---|---|
|
||||
| 蜂群 Runtime 调用 token | 已提供并验证可用;后续需要通过安全渠道配置到 Manager,不写入 Git | Manager 调 `POST /api/swarms` 需要 Bearer 鉴权 |
|
||||
| callback 凭据配置 | Runtime 配置 Manager 认可的 `AGENT_CALLBACK_SERVICE_TOKEN` 或 `AGENT_CALLBACK_SIGNING_SECRET` | Runtime 回调 Manager 必须通过鉴权 |
|
||||
| 两套部署边界 | 明确普通 sub 继续走 `20.212.121.126`,蜂群走 `52.139.240.116:8000` | 防止普通 sub 和蜂群混用 |
|
||||
| 创建响应字段 | `deployment_id`、`runtime_deployment_id`、`swarm_id`、`status` 字段保持稳定 | Manager 需要保存映射 |
|
||||
| callback deployment id 规则 | callback 中 `deployment_id` 应为 Manager deployment id,`runtime_deployment_id` 为 Runtime id,`swarm_id` 为 Runtime swarm id | Manager 根据这些字段落库和展示 |
|
||||
|
||||
callback 地址:
|
||||
|
||||
```text
|
||||
https://code.xinghanlab.com/api/agent/callbacks/swarm-events
|
||||
```
|
||||
|
||||
callback schema 查询:
|
||||
|
||||
```text
|
||||
GET https://code.xinghanlab.com/api/agent/callbacks/swarm-events/schema
|
||||
```
|
||||
|
||||
说明:具体 token / signing secret 不应写入 Markdown 或 Git,请通过安全渠道提供。
|
||||
|
||||
### P0 端到端能力
|
||||
|
||||
| 能力 | 当前情况 | 需要补充或证明 |
|
||||
|---|---|---|
|
||||
| Manager 创建蜂群 run | Runtime 直连创建已跑通;Manager 生产尚未切蜂群专用配置 | 将 Runtime token 安全配置到 Manager 后跑真实 Manager 创建 |
|
||||
| 动态任务图 | 当前线上 Runtime 只生成 1 个 `general` 任务,`workflow_mode=single_agent` | 至少根据目标生成 2-3 个可追踪任务,并回调多条 `task.created` |
|
||||
| Agent claim / running / heartbeat | 代码里有 dispatch 和事件能力 | 需要真实 Agent 连接、claim、running、heartbeat 证据 |
|
||||
| handoff | 事件类型和部分逻辑存在 | 需要真实 `handoff.requested` / `handoff.completed` 场景 |
|
||||
| 失败回流 | 有 `task.blocked`、`task.failed`、`task.retried` 事件类型 | 需要一次真实失败、重试或 blocked 证据 |
|
||||
| artifact | Agent 返回 `git_branch` 时会发 artifact | 需要真实 `artifact.created` 回 Manager,带 `uri`、`summary`、`artifact_type` |
|
||||
| 审批暂停 / 恢复 | high risk 会 `waiting_approval`,支持 decision 接口 | 需要真实 `approval.requested` -> Manager approve/reject -> Runtime 继续/阻塞 |
|
||||
| 日志 | `/logs` 当前主要来自 Runtime 事件 | 需要说明是否提供真实 Agent / Pod 日志,或至少返回可排障日志摘要 |
|
||||
| 指标 | `/metrics` 返回轻量聚合 | 需要补充真实运行时长、任务耗时、Agent 数、失败数、资源用量 |
|
||||
| 用量 / 成本 | 只有 Agent result 带 `usage` 时才回传 | 需要真实 NewAPI token、model_cost_usd、runtime_seconds 或明确暂无 |
|
||||
| callback 可靠性 | 当前代码 callback 失败记录 warning | 需要确认是否有重试、死信、人工重放;没有则列为未完成 |
|
||||
|
||||
## 5. 建议双方统一的蜂群创建请求
|
||||
|
||||
Heicode Manager 调蜂群 Runtime:
|
||||
|
||||
```http
|
||||
POST http://52.139.240.116:8000/api/swarms
|
||||
Authorization: Bearer <runtime-service-token>
|
||||
X-User-ID: <manager-user-id>
|
||||
X-Binding-Scope: <binding-scope>
|
||||
X-Correlation-ID: <correlation-id>
|
||||
X-Idempotency-Key: manager-<manager-deployment-id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求体建议:
|
||||
|
||||
```json
|
||||
{
|
||||
"orchestration_plan": {
|
||||
"intent_id": "task_xxx",
|
||||
"objective": "完成本轮用户目标",
|
||||
"sub_mode": "goal_driven_swarm",
|
||||
"risk_level": "medium",
|
||||
"budget": {
|
||||
"duration_seconds": 3600,
|
||||
"token_limit": 20000,
|
||||
"max_cost_usd": 8
|
||||
},
|
||||
"user_context": {
|
||||
"user_id": "22",
|
||||
"channel_id": "heicode",
|
||||
"binding_scope": "task-task-xxx"
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"task_id": "plan-1",
|
||||
"role": "planner",
|
||||
"title": "目标理解与任务生成",
|
||||
"description": "整理用户目标、资源约束和验收标准",
|
||||
"depends_on": []
|
||||
},
|
||||
{
|
||||
"task_id": "build-1",
|
||||
"role": "builder",
|
||||
"title": "代码或文档变更",
|
||||
"description": "根据目标生成可验证交付物",
|
||||
"depends_on": ["plan-1"]
|
||||
},
|
||||
{
|
||||
"task_id": "verify-1",
|
||||
"role": "reviewer",
|
||||
"title": "验证与交付整理",
|
||||
"description": "检查交付物并形成验收摘要",
|
||||
"depends_on": ["build-1"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"callback": {
|
||||
"url": "https://code.xinghanlab.com/api/agent/callbacks/swarm-events",
|
||||
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agent-callback-signing-key",
|
||||
"subscribed_events": [
|
||||
"deployment.status_changed",
|
||||
"task.created",
|
||||
"task.claimed",
|
||||
"task.running",
|
||||
"task.heartbeat",
|
||||
"task.blocked",
|
||||
"task.retried",
|
||||
"task.failed",
|
||||
"task.completed",
|
||||
"handoff.requested",
|
||||
"handoff.completed",
|
||||
"approval.requested",
|
||||
"artifact.created",
|
||||
"budget.alert",
|
||||
"timeline.updated"
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"manager_deployment_id": "dep_xxx",
|
||||
"heicode_deployment_id": "dep_xxx",
|
||||
"correlation_id": "corr_xxx",
|
||||
"heicode_runtime_bridge": true,
|
||||
"runtime_mode": "swarm"
|
||||
},
|
||||
"billing_context": {
|
||||
"provider": "newapi",
|
||||
"default_model_id": "model_xxx",
|
||||
"allowed_model_ids": ["model_xxx"],
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key"
|
||||
},
|
||||
"resource_grants": [
|
||||
{
|
||||
"grant_id": "grant-task-git",
|
||||
"resource_id": "repo-main",
|
||||
"resource_type": "git",
|
||||
"permission_scope": ["repo:read", "repo:write:feature-branches"],
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
|
||||
"ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
|
||||
"target_role": "builder"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Secret 引用格式需要统一
|
||||
|
||||
Heicode Manager 当前正式约束建议使用:
|
||||
|
||||
```text
|
||||
azkv://<vault>/secrets/<name>
|
||||
```
|
||||
|
||||
例如:
|
||||
|
||||
```text
|
||||
azkv://heicode-kv.vault.azure.net/secrets/repo-main
|
||||
azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key
|
||||
```
|
||||
|
||||
HeiCode-Swarm 文档中示例为:
|
||||
|
||||
```text
|
||||
azkv://heicode/git-write-token
|
||||
```
|
||||
|
||||
建议 Agent Manager / 蜂群 Runtime 确认是否可以接受标准 Key Vault 形式,并在示例文档中同步,避免后续联调时因为 `secret_ref` 格式不一致失败。
|
||||
|
||||
## 7. Manager 侧后续配置建议
|
||||
|
||||
Heicode Manager 需要保留普通 sub 和蜂群两套 Runtime 配置。建议后续拆成:
|
||||
|
||||
```text
|
||||
# 普通 sub 敏捷模式
|
||||
AGENT_RUNTIME_BASE_URL=http://20.212.121.126
|
||||
AGENT_RUNTIME_CREATE_PATH=/api/swarms
|
||||
|
||||
# 蜂群模式
|
||||
SWARM_RUNTIME_BASE_URL=http://52.139.240.116:8000
|
||||
SWARM_RUNTIME_CREATE_PATH=/api/swarms
|
||||
SWARM_RUNTIME_STOP_PATH=/api/swarms/{swarm_id}/stop
|
||||
SWARM_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
|
||||
SWARM_RUNTIME_SERVICE_TOKEN=<runtime-service-token>
|
||||
```
|
||||
|
||||
如果短期 Manager 还只有一套 `AGENT_RUNTIME_*`,只能临时切换到蜂群 Runtime 做专项联调,不能同时代表普通 sub 和蜂群都在线。
|
||||
|
||||
## 8. 蜂群 MVP 验收口径
|
||||
|
||||
根据蜂群设计文档和 Heicode 产品资料包,蜂群 MVP 不是“接口能创建一个 run”就完成。最小验收应至少满足:
|
||||
|
||||
| 验收项 | 成功标准 |
|
||||
|---|---|
|
||||
| 创建 | Manager 调 `POST /api/swarms` 成功,Runtime 返回真实 `swarm_id` |
|
||||
| 任务图 | Runtime 至少生成 2 个可追踪任务,并可通过 `/tasks` 查询 |
|
||||
| Agent 执行 | 至少一个真实 Agent claim、running、completed |
|
||||
| 交接或失败回流 | 至少出现一次 handoff,或一次失败/blocked/retry 并可解释 |
|
||||
| artifact | 至少 1 个真实 artifact 回 Manager,可在 Manager 查询 |
|
||||
| 审批 | high risk 流程能 `approval.requested`,Manager approve/reject 后 Runtime 状态变化 |
|
||||
| 日志 | Manager 或 Runtime 能查到可排障日志,不只是空占位 |
|
||||
| 指标 | 能看到任务数、状态、耗时、Agent 数、失败数等真实指标 |
|
||||
| 用量 | 能回传 token、模型成本或明确当前未接真实模型成本 |
|
||||
| 安全 | 请求、callback、日志、artifact metadata 不出现明文长期密钥 |
|
||||
| 幂等 | 同一个 `X-Idempotency-Key` 重试不会创建重复 swarm |
|
||||
| 停止 | Manager stop 后 Runtime 状态为 `stopped`,不继续执行 |
|
||||
|
||||
## 9. 建议下一次联调步骤
|
||||
|
||||
1. Heicode Manager 新增或临时配置蜂群 Runtime base URL 和 Runtime Bearer token。
|
||||
2. Agent Manager / 蜂群 Runtime 确认 callback token 或 HMAC secret 已和 Heicode Manager 生产端一致。
|
||||
3. 使用测试用户通过 Manager 创建蜂群 run。
|
||||
4. 验证 Manager 保存 `runtime_deployment_id` 和 `runtime_swarm_id`。
|
||||
5. 验证 Runtime 不再只生成 single-agent fallback,而是生成多任务 task graph。
|
||||
6. 验证 Runtime 主动 callback:
|
||||
- `deployment.status_changed`
|
||||
- `task.created`
|
||||
- `task.claimed`
|
||||
- `task.running`
|
||||
- `task.completed`
|
||||
- `artifact.created`
|
||||
- `timeline.updated`
|
||||
7. 验证查询:
|
||||
- `GET /api/swarms/{swarm_id}`
|
||||
- `GET /api/swarms/{swarm_id}/tasks`
|
||||
- `GET /api/swarms/{swarm_id}/logs`
|
||||
- `GET /api/swarms/{swarm_id}/metrics`
|
||||
8. 验证 high risk 审批:
|
||||
- Runtime 回调 `approval.requested`
|
||||
- Manager approve
|
||||
- Runtime 收到 decision 并继续
|
||||
9. 验证 stop:
|
||||
- Manager stop
|
||||
- Runtime stopped
|
||||
- 不再继续发 running/completed callback
|
||||
|
||||
## 10. 当前结论
|
||||
|
||||
蜂群 Runtime 当前已经具备可联调的接口骨架和一部分运行时能力,但还没有完成 Heicode 产品设计要求的完整蜂群 MVP 生产验收。
|
||||
|
||||
当前最优先阻塞项是:
|
||||
|
||||
1. 将已验证可用的蜂群 Runtime Bearer token 通过安全渠道配置到 Heicode Manager。
|
||||
2. 确认 Runtime 回调 Manager 的 callback token 或 HMAC secret 已生效。
|
||||
3. 修正线上 Runtime 只生成 1 个 `general` 任务的问题,使其按目标生成真实多任务 task graph。
|
||||
4. 确认线上镜像/代码 commit 与 Gitee main 是否一致;如果不一致,需要先部署包含多任务 task graph 逻辑的版本。
|
||||
5. 跑通一次真实 Manager -> 蜂群 Runtime -> Manager callback 的端到端链路。
|
||||
6. 补齐真实 Agent 执行、handoff/失败回流、artifact、日志、指标、usage/cost 的生产证据。
|
||||
@@ -0,0 +1,403 @@
|
||||
# Agent Manager / HeiCode-Swarm 蜂群对接改造请求
|
||||
|
||||
更新时间:2026-05-27
|
||||
发起方:Heicode Manager
|
||||
用途:给 Agent Manager / HeiCode-Swarm 侧确认需要补齐或对齐的生产接口,便于 Heicode 桌面客户端 -> Manager -> 蜂群 Runtime 跑完整蜂群流程。
|
||||
|
||||
## 一、当前 Manager 已准备好的能力
|
||||
|
||||
| 能力 | Manager 状态 |
|
||||
|---|---|
|
||||
| 用户态创建 | `POST /api/agent/user/deployments`、`POST /api/swarms` 已可创建 Manager deployment |
|
||||
| Runtime 创建桥接 | 可通过 `AGENT_RUNTIME_BASE_URL` + `AGENT_RUNTIME_CREATE_PATH` 调用 Runtime |
|
||||
| Runtime 停止桥接 | 可通过 `AGENT_RUNTIME_STOP_PATH` 停止 Runtime deployment |
|
||||
| 回调接收 | `POST /api/agent/callbacks/swarm-events` 已支持事件、artifact、approval、timeline 回调 |
|
||||
| 回调安全 | 支持 `X-Agent-Service-Token` 或 `X-Agent-Signature` HMAC |
|
||||
| 幂等 | `event_id` / `idempotency_key` 去重 |
|
||||
| 审批结果回传 | 用户 approve/reject 后,Manager 可调用 Runtime approval decision 接口 |
|
||||
| 密钥边界 | Manager 只传 `secret_ref` / `credential_ref`,不传长期明文密钥 |
|
||||
|
||||
## 二、Runtime 需要提供的接口
|
||||
|
||||
### 1. 健康检查
|
||||
|
||||
```http
|
||||
GET /api/agent/health
|
||||
```
|
||||
|
||||
如果蜂群侧沿用现有 Orchestrator,也可以临时配置为:
|
||||
|
||||
```http
|
||||
GET /health
|
||||
```
|
||||
|
||||
响应建议:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"status": "healthy",
|
||||
"service": "heicode-swarm-runtime",
|
||||
"version": "x.y.z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 创建 Swarm Run
|
||||
|
||||
生产目标接口:
|
||||
|
||||
```http
|
||||
POST /api/swarms
|
||||
Authorization: Bearer <service_token>
|
||||
X-Correlation-ID: <correlation_id>
|
||||
X-Idempotency-Key: manager-<deployment_id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Manager 当前也可兼容配置:
|
||||
|
||||
```text
|
||||
AGENT_RUNTIME_CREATE_PATH=/api/swarms
|
||||
```
|
||||
|
||||
请求核心字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"orchestration_plan": {
|
||||
"sub_mode": "agile",
|
||||
"risk_level": "high",
|
||||
"user_context": {
|
||||
"user_id": "22",
|
||||
"channel_id": "heicode"
|
||||
},
|
||||
"billing_context": {
|
||||
"provider": "newapi",
|
||||
"default_model_id": "model_xxx",
|
||||
"allowed_model_ids": ["model_xxx"],
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key"
|
||||
},
|
||||
"agents": [],
|
||||
"resource_grants": []
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"role": "backend",
|
||||
"resource_grants": []
|
||||
}
|
||||
],
|
||||
"resource_grants": [
|
||||
{
|
||||
"grant_id": "grant-task-backend",
|
||||
"resource_type": "git",
|
||||
"permission_scope": ["read", "write"],
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main"
|
||||
}
|
||||
],
|
||||
"callback": {
|
||||
"url": "https://code.xinghanlab.com/api/agent/callbacks/swarm-events",
|
||||
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agent-callback-signing-key",
|
||||
"subscribed_events": [
|
||||
"deployment.status_changed",
|
||||
"task.created",
|
||||
"task.claimed",
|
||||
"task.heartbeat",
|
||||
"task.completed",
|
||||
"task.failed",
|
||||
"handoff.requested",
|
||||
"approval.requested",
|
||||
"artifact.created",
|
||||
"timeline.updated"
|
||||
]
|
||||
},
|
||||
"metadata": {
|
||||
"manager_deployment_id": "dep_xxx",
|
||||
"heicode_runtime_bridge": true,
|
||||
"correlation_id": "corr_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
响应必须返回至少一个 Runtime 标识:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deployment_id": "runtime-dep-123",
|
||||
"swarm_id": "swarm-123",
|
||||
"status": "created"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
兼容要求:
|
||||
|
||||
1. `deployment_id` 可作为 Runtime deployment id。
|
||||
2. `swarm_id` 用于蜂群任务图、审批和事件关联。
|
||||
3. 如果 Runtime 只返回顶层 JSON,不包 `data`,Manager 也能解析。
|
||||
4. 不要要求 Manager 发送长期明文密钥。
|
||||
|
||||
### 3. 停止 Runtime Run
|
||||
|
||||
默认路径:
|
||||
|
||||
```http
|
||||
POST /api/agent/deployments/{deployment_id}/stop
|
||||
Authorization: Bearer <service_token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"reason": "Heicode Manager requested stop",
|
||||
"manager_deployment_id": "dep_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deployment_id": "runtime-dep-123",
|
||||
"swarm_id": "swarm-123",
|
||||
"status": "stopped"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果蜂群侧希望使用:
|
||||
|
||||
```http
|
||||
POST /api/swarms/{swarm_id}/stop
|
||||
```
|
||||
|
||||
则 Manager 可通过环境变量配置:
|
||||
|
||||
```text
|
||||
AGENT_RUNTIME_STOP_PATH=/api/swarms/{swarm_id}/stop
|
||||
```
|
||||
|
||||
## 三、Runtime 回调 Manager
|
||||
|
||||
统一回调入口:
|
||||
|
||||
```http
|
||||
POST https://code.xinghanlab.com/api/agent/callbacks/swarm-events
|
||||
X-Agent-Service-Token: <callback_token>
|
||||
X-Agent-Event-Id: <event_id>
|
||||
X-Correlation-ID: <correlation_id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
也支持 HMAC:
|
||||
|
||||
```http
|
||||
X-Agent-Timestamp: <unix_ms>
|
||||
X-Agent-Signature: sha256=<hex>
|
||||
```
|
||||
|
||||
签名内容:
|
||||
|
||||
```text
|
||||
timestamp + "." + event_id + "." + raw_body
|
||||
```
|
||||
|
||||
### 通用事件 envelope
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_123",
|
||||
"idempotency_key": "evt_123",
|
||||
"event_type": "task.completed",
|
||||
"deployment_id": "dep_xxx",
|
||||
"swarm_id": "swarm-123",
|
||||
"agent_instance_id": "agent-backend-1",
|
||||
"task_id": "task-backend-1",
|
||||
"occurred_at": "2026-05-27T10:00:00Z",
|
||||
"correlation_id": "corr_xxx",
|
||||
"source": "heicode-swarm-runtime",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
必须支持的事件类型:
|
||||
|
||||
| event_type | 用途 |
|
||||
|---|---|
|
||||
| `deployment.status_changed` | Runtime 整体状态变化 |
|
||||
| `task.created` | 任务图新增任务 |
|
||||
| `task.claimed` | Agent claim 任务 |
|
||||
| `task.heartbeat` | Agent 执行心跳 |
|
||||
| `task.completed` | 任务完成 |
|
||||
| `task.failed` | 任务失败 |
|
||||
| `handoff.requested` | 任务交接 |
|
||||
| `handoff.completed` | 交接完成 |
|
||||
| `approval.requested` | 请求用户审批 |
|
||||
| `artifact.created` | 产物生成 |
|
||||
| `timeline.updated` | 用户可见时间线 |
|
||||
|
||||
### artifact 回调
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_artifact_1",
|
||||
"event_type": "artifact.created",
|
||||
"deployment_id": "dep_xxx",
|
||||
"swarm_id": "swarm-123",
|
||||
"task_id": "task-backend-1",
|
||||
"artifact": {
|
||||
"artifact_id": "art_backend_patch_1",
|
||||
"artifact_type": "code_patch",
|
||||
"title": "Backend patch",
|
||||
"summary": "完成后端接口修改",
|
||||
"uri": "git://repo#agent-backend-1-results",
|
||||
"checksum": "sha256:xxx",
|
||||
"metadata": {
|
||||
"commit_sha": "abc123",
|
||||
"branch": "agent-backend-1-results",
|
||||
"redacted": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### approval 请求回调
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_approval_1",
|
||||
"event_type": "approval.requested",
|
||||
"deployment_id": "dep_xxx",
|
||||
"swarm_id": "swarm-123",
|
||||
"task_id": "task-deploy-1",
|
||||
"payload": {
|
||||
"approval_id": "runtime-approval-1",
|
||||
"operation": "production_deploy",
|
||||
"resource_id": "azure-prod",
|
||||
"resource_type": "cloud_resource",
|
||||
"resource_scope": "/subscriptions/.../resourceGroups/prod",
|
||||
"target_role": "ops",
|
||||
"risk_level": "high",
|
||||
"requires_credential": true,
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/azure-prod-sp",
|
||||
"ttl_seconds": 900,
|
||||
"reason": "部署到生产环境需要用户确认"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 四、Manager 回传审批结果给 Runtime
|
||||
|
||||
Manager 已支持调用该接口。蜂群侧需要实现接收。
|
||||
|
||||
默认路径:
|
||||
|
||||
```http
|
||||
POST /api/swarms/{swarm_id}/approvals/{approval_id}
|
||||
Authorization: Bearer <service_token>
|
||||
X-Correlation-ID: <correlation_id>
|
||||
X-Idempotency-Key: approval-decision-<approval_id>-<decision>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Manager 可配置:
|
||||
|
||||
```text
|
||||
AGENT_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"approval_id": "runtime-approval-1",
|
||||
"decision": "approved",
|
||||
"manager_deployment_id": "dep_xxx",
|
||||
"runtime_deployment_id": "runtime-dep-123",
|
||||
"swarm_id": "swarm-123",
|
||||
"operation": "production_deploy",
|
||||
"resource_id": "azure-prod",
|
||||
"resource_type": "cloud_resource",
|
||||
"resource_scope": "/subscriptions/.../resourceGroups/prod",
|
||||
"target_role": "ops",
|
||||
"risk_level": "high",
|
||||
"requires_credential": true,
|
||||
"credential_ref": "lease://agent/lease_xxx",
|
||||
"lease_id": "lease_xxx",
|
||||
"lease_expires_at": 1770000000000,
|
||||
"decided_by": "user:22",
|
||||
"reason": "用户确认部署",
|
||||
"decided_at": 1770000000000
|
||||
}
|
||||
```
|
||||
|
||||
拒绝时:
|
||||
|
||||
```json
|
||||
{
|
||||
"approval_id": "runtime-approval-1",
|
||||
"decision": "rejected",
|
||||
"manager_deployment_id": "dep_xxx",
|
||||
"runtime_deployment_id": "runtime-dep-123",
|
||||
"swarm_id": "swarm-123",
|
||||
"reason": "风险过高",
|
||||
"decided_by": "user:22",
|
||||
"decided_at": 1770000000000
|
||||
}
|
||||
```
|
||||
|
||||
安全要求:
|
||||
|
||||
1. Manager 不会在该请求中发送 `secret_ref`。
|
||||
2. 如果审批通过且需要凭证,只发送短期 `credential_ref = lease://agent/...`。
|
||||
3. Runtime 不得把 `credential_ref`、service token、签名密钥写入日志或 artifact metadata。
|
||||
4. Runtime 收到 `approved` 后继续原高危动作;收到 `rejected` 后停止该动作并回调 `timeline.updated` 或 `task.failed/blocked`。
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"approval_id": "runtime-approval-1",
|
||||
"status": "accepted"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、当前 HeiCode-Swarm 现状与建议
|
||||
|
||||
当前 `HeiCode-Swarm` 可见 Orchestrator 入口是:
|
||||
|
||||
| 当前入口 | 可用性 | 建议 |
|
||||
|---|---|---|
|
||||
| `GET /health` | 可直接作为健康检查 | Manager `AGENT_RUNTIME_HEALTH_PATH=/health` |
|
||||
| `POST /tasks` | 可临时创建任务 | 只能做兼容桥接,不建议作为最终生产契约 |
|
||||
| `GET /tasks` | 可查任务 | 后续需要映射到 callback/timeline |
|
||||
| `GET /agents` | 可查 Agent | 后续需要回调 agent status |
|
||||
| `GET /handoffs` | 可查 handoff | 后续需要回调 handoff events |
|
||||
| `GET /metrics` | 可查指标 | 后续需要按 deployment/swarm 关联 |
|
||||
| `WS /ws/{agent_id}` | Agent 内部通道 | Manager 不直接接 WS |
|
||||
|
||||
建议蜂群侧优先补正式 `POST /api/swarms` 和 approval decision 接口;如果短期只想先跑通,可由 Manager 临时把 create path 指到 `/tasks`,但这会缺少 `secret_ref`、resource grants、approval policy、artifact callback 等正式字段。
|
||||
|
||||
## 六、联调验收
|
||||
|
||||
最小验收顺序:
|
||||
|
||||
1. Manager 调 `GET /api/agent/health` 或 `/health` 返回 healthy。
|
||||
2. Manager 调 Runtime create,拿到 `runtime_deployment_id` 和 `swarm_id`。
|
||||
3. Runtime 回调 `deployment.status_changed`,Manager timeline 可见。
|
||||
4. Runtime 回调 `task.claimed`、`task.completed`,Manager timeline 可见。
|
||||
5. Runtime 回调 `artifact.created`,Manager artifacts 可见。
|
||||
6. Runtime 回调 `approval.requested`,Manager/客户端能看到 pending approval。
|
||||
7. 用户 approve/reject 后,Runtime 收到 approval decision。
|
||||
8. Runtime 根据 decision 继续或停止,并回调最终结果。
|
||||
9. 全链路无明文长期密钥、模型 key、云 access key、私钥、连接串。
|
||||
@@ -0,0 +1,961 @@
|
||||
# Manager → Agent 平台接口参数文档
|
||||
|
||||
**版本**: v0.3(P1/P5 联调契约)
|
||||
**生效日期**: 2026-05-03
|
||||
**状态**: 联调准备;当前仓库提供 Manager 侧最小验证端点,生产 Agent 平台部署尚未在本文档中宣称完成。
|
||||
**方向**: Heicode Manager 主动请求 Agent 平台;Agent 平台返回部署、日志、监控与审计状态。
|
||||
**范围**: 创建/停止子 Agent 部署、查询部署、获取事件/日志/监控快照、解析 SK 快照、查询审计日志,以及 Agent 辅助 NewAPI 重建/部署的参数约定。
|
||||
|
||||
> 2026-05-04 边界修正:Manager 当前不把 `tenant/project` 作为产品、认证或扣费主轴。新请求应使用 `user_context.user_id`、`user_context.channel_id`、`resource_grants[].binding_scope`、`billing_context(newapi)` 和 `agent_runtime(agent)`。本文中仍出现的 `tenant_id/project_id` 只表示旧字段兼容或历史接口命名,不应作为新功能设计依据。
|
||||
**安全红线**: 请求体只允许传资源元数据、权限范围与 `secret_ref`/环境变量名;不得传明文密码、Token、私钥、连接串或云访问密钥。
|
||||
|
||||
> 本文档描述 Manager 对 Agent 平台的出站集成契约。当前仓库中 `/api/agent/*` 是 Manager 侧最小控制面/模拟端点,用于校验同一套 payload 结构;生产接入时,Manager 应将下列请求发送到 Agent 平台网关。
|
||||
|
||||
## 0. 概述
|
||||
|
||||
本文档按登录接口文档的对接方式组织:先定义接入信息,再逐个接口给出请求、响应、错误和安全约束。接口分组如下:
|
||||
|
||||
| 接口 | 用途 | 当前性质 |
|
||||
|---|---|---|
|
||||
| `POST /api/agent/deployments` | 创建子 Agent/运维任务部署,含 NewAPI 重建/部署场景 | 必需 |
|
||||
| `GET /api/agent/deployments` | 查询部署列表 | 必需 |
|
||||
| `GET /api/agent/deployments/{deployment_id}` | 查询单个部署详情 | 必需 |
|
||||
| `POST /api/agent/deployments/{deployment_id}/stop` | 停止部署或取消排队任务 | 必需 |
|
||||
| `GET /api/agent/deployments/{deployment_id}/logs` | 拉取部署日志 | 必需 |
|
||||
| `GET /api/agent/deployments/{deployment_id}/logs/stream` | 实时日志 SSE | 可选 |
|
||||
| `GET /api/agent/projects/{project_id}/dashboard-snapshot` | 资源作用域监控快照;路径名保留旧兼容,参数值按 `binding_scope` 解释 | 必需 |
|
||||
| `GET /api/agent/deployments/{deployment_id}/metrics` | 单部署指标序列 | 建议 |
|
||||
| `GET /api/agent/deployments/{deployment_id}/events` | 部署事件 | 必需 |
|
||||
| `GET /api/agent/audit-logs` | 审计日志 | 必需 |
|
||||
| `POST /api/agent/sk-snapshots/resolve` | 触发 SK 快照解析 | 必需 |
|
||||
| `GET /api/agent/deployments/{deployment_id}/sk-snapshots` | 查询 SK 快照 | 必需 |
|
||||
|
||||
> 不在本文档范围:真实 Secret Store 写入、生产 SSH 登录、云账号授权回调、NewAPI 管理后台开放。生产部署动作只有实际执行并通过日志/监控/审计验证后,才能在报告中标记为“已部署”。
|
||||
|
||||
---
|
||||
|
||||
## 1. 接入约定
|
||||
|
||||
### 1.1 Base URL
|
||||
|
||||
由部署环境配置,不写入仓库。例如:
|
||||
|
||||
```text
|
||||
AGENT_PLATFORM_BASE_URL=https://agent-platform.example.com
|
||||
```
|
||||
|
||||
联调环境建议使用独立域名或内网网关,示例不得包含真实凭据:
|
||||
|
||||
```text
|
||||
AGENT_PLATFORM_BASE_URL=https://staging-agent.example.com
|
||||
MANAGER_SERVICE_TOKEN_SECRET_REF=azkv://heicode-kv.vault.azure.net/secrets/manager-service-agent-platform-service-token
|
||||
```
|
||||
|
||||
完整路径示例:
|
||||
|
||||
```http
|
||||
POST https://agent-platform.example.com/api/agent/deployments
|
||||
```
|
||||
|
||||
### 1.2 通用 Header
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
|---|---:|---|
|
||||
| `Authorization: Bearer <manager-service-token>` | 是 | Manager 服务身份令牌,由 Secret Store/运行环境注入。 |
|
||||
| `Content-Type: application/json` | POST/PUT 是 | JSON 请求体。 |
|
||||
| `X-User-Id: <user_id>` | 建议 | 登录用户边界;也可从 Manager 服务端 token 或 body `user_context.user_id` 推导。 |
|
||||
| `X-Binding-Scope: <binding_scope>` | 建议 | Git/SK/云资源作用域;也可从 `resource_grants[].binding_scope` 推导。 |
|
||||
| `X-Correlation-Id: <uuid>` | 是 | Manager 生成,全链路追踪。 |
|
||||
| `X-Request-Id: <uuid>` | 建议 | 单次 HTTP 请求追踪 ID,可与 correlation_id 不同。 |
|
||||
| `Idempotency-Key: <uuid>` | 创建类接口建议 | 避免重试造成重复部署。 |
|
||||
|
||||
### 1.3 通用响应包裹
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
失败:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "human readable message",
|
||||
"error": {
|
||||
"code": "POLICY_REJECTED",
|
||||
"message": "human readable message",
|
||||
"request_id": "req_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议错误码:
|
||||
|
||||
| code | 场景 |
|
||||
|---|---|
|
||||
| `POLICY_REJECTED` | 缺必填字段、权限策略不满足、风险等级非法。 |
|
||||
| `BUDGET_EXCEEDED` | 超过 token/金额/时长预算。 |
|
||||
| `MODEL_NOT_ALLOWED` | agent 默认模型不在允许列表内。 |
|
||||
| `FORBIDDEN_SCOPE` | Header 与 body/query 的用户或资源作用域不一致。 |
|
||||
| `RESOURCE_GRANT_INVALID` | Resource Grant 字段缺失、跨用户/跨资源作用域/角色不匹配。 |
|
||||
| `RESOURCE_GRANT_SECRET_REF_REQUIRED` | 凭据型资源缺少 `secret_ref`。 |
|
||||
| `RESOURCE_GRANT_SECRET_REJECTED` | 请求中出现明文密钥字段。 |
|
||||
| `SK_SOURCE_UNRESOLVABLE` | SK 来源不可解析。 |
|
||||
| `DEPLOYMENT_CONFLICT` | 部署不存在、状态冲突或重复提交。 |
|
||||
| `NOT_FOUND` | deployment、agent instance、snapshot 或审计资源不存在。 |
|
||||
| `CURSOR_EXPIRED` | 分页游标过期或不属于当前查询条件。 |
|
||||
| `RATE_LIMITED` | 平台限流;响应头建议包含 `Retry-After`。 |
|
||||
| `INTERNAL_ERROR` | 平台内部错误;Manager 应记录 request_id 并重试或提示稍后处理。 |
|
||||
|
||||
### 1.4 通用错误处理
|
||||
|
||||
| HTTP | business code | Manager 处理建议 |
|
||||
|---:|---|---|
|
||||
| 400 | `POLICY_REJECTED` / `RESOURCE_GRANT_INVALID` | 标记部署失败,展示校验原因,不自动重试。 |
|
||||
| 401 | `UNAUTHORIZED` | 检查 Manager 服务令牌的 `secret_ref`/环境注入,不把令牌写入日志。 |
|
||||
| 403 | `FORBIDDEN_SCOPE` / `MODEL_NOT_ALLOWED` | 阻断本次部署,写审计事件。 |
|
||||
| 404 | `NOT_FOUND` | 对查询类接口返回空态;对控制类接口提示资源不存在。 |
|
||||
| 409 | `DEPLOYMENT_CONFLICT` | 使用 `Idempotency-Key` 查询既有结果,避免重复创建。 |
|
||||
| 410 | `CURSOR_EXPIRED` | 丢弃 cursor,使用 `since` 重新拉取。 |
|
||||
| 422 | `RESOURCE_GRANT_SECRET_REF_REQUIRED` / `RESOURCE_GRANT_SECRET_REJECTED` | 要求 Manager 重新生成只含 `secret_ref` 的 payload。 |
|
||||
| 429 | `RATE_LIMITED` | 按 `Retry-After` 退避重试。 |
|
||||
| 500/503 | `INTERNAL_ERROR` | 指数退避重试;超过阈值后转人工排查。 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 创建子 Agent 部署
|
||||
|
||||
### 2.1 Endpoint
|
||||
|
||||
```http
|
||||
POST /api/agent/deployments
|
||||
```
|
||||
|
||||
### 2.2 请求体
|
||||
|
||||
```json
|
||||
{
|
||||
"orchestration_plan": {
|
||||
"intent_id": "intent_20260502_001",
|
||||
"template_hint": "manager-resource-binding",
|
||||
"objective": "为已绑定的代码仓库启动 builder 子 Agent,允许其读取 SK 并在限定路径内提交代码",
|
||||
"risk_level": "low",
|
||||
"budget": {
|
||||
"max_tokens": 100000,
|
||||
"max_cost_usd": 30,
|
||||
"max_duration_sec": 7200
|
||||
},
|
||||
"user_context": {
|
||||
"user_id": "user_123",
|
||||
"email": "user@example.com",
|
||||
"role": "user",
|
||||
"channel_id": "channel_abc",
|
||||
"subscription_tier": "pro"
|
||||
},
|
||||
"billing_context": {
|
||||
"provider": "newapi",
|
||||
"newapi_user_ref": "newapi_user_123",
|
||||
"newapi_group": "development",
|
||||
"quota_ref": "newapi_token_or_group_quota_ref"
|
||||
},
|
||||
"agent_runtime": {
|
||||
"platform": "agent",
|
||||
"agents": [
|
||||
{
|
||||
"role": "builder",
|
||||
"model_ref": "agent_model_profile_builder",
|
||||
"instance_count": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"constraints": {
|
||||
"allowed_model_ids": ["gpt-5.4-mini", "gpt-5.4"]
|
||||
},
|
||||
"metadata": {
|
||||
"tenant_id": "legacy-user-scope",
|
||||
"project_id": "legacy-resource-scope",
|
||||
"correlation_id": "corr_20260502_001"
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"role_template": "builder",
|
||||
"goal": "按 Manager 下发的任务在允许资源内完成实现、验证并回传状态",
|
||||
"default_model_id": "gpt-5.4-mini",
|
||||
"sk_sources": [
|
||||
{
|
||||
"type": "git",
|
||||
"mime": "text/markdown",
|
||||
"repo_ref": {
|
||||
"connection_id": "conn_sk_repo_001",
|
||||
"repo_url": "https://example.com/org/sk-repo.git",
|
||||
"ref": "main",
|
||||
"paths": ["skills/heicode/**", "AGENTS.md"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"runtime_execution": {
|
||||
"profile_id": "aks-codex-standard",
|
||||
"cloud_principal_refs": ["principal://users/user_123/agent-runtime"],
|
||||
"network_policy_ref": "netpol://bindings/repo_default/restricted-egress"
|
||||
},
|
||||
"sk_access_policy": {
|
||||
"policy_ref": "sk-policy://bindings/repo_default/default-readonly",
|
||||
"deny_skill_ids": ["dangerous-shell"],
|
||||
"inherit_deployment_defaults": true
|
||||
},
|
||||
"resource_grants": [
|
||||
{
|
||||
"grant_id": "grant_git_repo_001",
|
||||
"resource_id": "res_git_repo_001",
|
||||
"resource_type": "git",
|
||||
"user_id": "user_123",
|
||||
"binding_scope": "repo_default",
|
||||
"tenant_id": "legacy-user-scope",
|
||||
"project_id": "legacy-resource-scope",
|
||||
"target_role": "builder",
|
||||
"target_agent_ref": "agent-builder-1",
|
||||
"permission_scope": ["repo:read", "repo:write:current-branch"],
|
||||
"constraints": {
|
||||
"ref": "main",
|
||||
"allowed_paths": "heicode/**,docs/**",
|
||||
"forbid_branch_create": "true"
|
||||
},
|
||||
"metadata": {
|
||||
"provider": "gitee",
|
||||
"repo_url": "https://example.com/org/repo.git"
|
||||
},
|
||||
"status": "active",
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/user-123-repo-default-res-git-repo-001",
|
||||
"audit": {
|
||||
"created_by": "manager",
|
||||
"approval_id": "approval_001"
|
||||
}
|
||||
},
|
||||
{
|
||||
"grant_id": "grant_doc_001",
|
||||
"resource_id": "res_project_doc_001",
|
||||
"resource_type": "project_doc",
|
||||
"user_id": "user_123",
|
||||
"binding_scope": "repo_default",
|
||||
"tenant_id": "legacy-user-scope",
|
||||
"project_id": "legacy-resource-scope",
|
||||
"target_role": "builder",
|
||||
"target_agent_ref": "agent-builder-1",
|
||||
"permission_scope": ["doc:read"],
|
||||
"constraints": {
|
||||
"doc_paths": "docs/heicode.md,docs/plan.md"
|
||||
},
|
||||
"metadata": {
|
||||
"doc_ref": "project-doc://project-a/docs-mainline"
|
||||
},
|
||||
"status": "active",
|
||||
"audit": {
|
||||
"created_by": "manager"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 字段说明
|
||||
|
||||
#### orchestration_plan
|
||||
|
||||
| 字段 | 类型 | 必填 | 约束/说明 |
|
||||
|---|---|---:|---|
|
||||
| `intent_id` | string | 是 | Manager 侧意图 ID,用于幂等、审计和追踪。 |
|
||||
| `template_hint` | string | 是 | Agent 平台选择编排模板的提示,如 `manager-resource-binding`。 |
|
||||
| `objective` | string | 是 | 本次部署目标,应是自然语言但不能含密钥。 |
|
||||
| `risk_level` | enum | 是 | `low` / `medium` / `high`。高风险必须携带客户端审批证据;缺失或不匹配时只能只读或拒绝执行。 |
|
||||
| `budget.max_tokens` | int | 是 | 当前策略上限建议不超过 `500000`。 |
|
||||
| `budget.max_cost_usd` | number | 是 | 当前策略上限建议不超过 `200`。 |
|
||||
| `budget.max_duration_sec` | int | 是 | 当前策略上限建议不超过 `86400`。 |
|
||||
| `user_context` | object | 建议 | 登录用户上下文,优先使用 Heicode/Agent 登录返回的 `user.id`/`channelId`。 |
|
||||
| `billing_context` | object | 条件 | NewAPI 扣费上下文;只表达 user/token/group/quota 映射,不表达子 Agent 模型或实例数。 |
|
||||
| `agent_runtime` | object | 条件 | Agent 平台运行时上下文;表达子 Agent 角色、模型 profile 和实例数,不承载 NewAPI key 或扣费对象。 |
|
||||
| `constraints.allowed_model_ids` | string[] | 否 | agent 的 `default_model_id` 如填写,必须在此列表内。 |
|
||||
| `metadata.tenant_id` | string | 否 | 旧兼容字段;新实现不得作为产品租户边界。 |
|
||||
| `metadata.project_id` | string | 否 | 旧兼容字段;新实现不得作为项目账本边界。 |
|
||||
| `metadata.correlation_id` | string | 是 | 全链路追踪 ID。 |
|
||||
| `agents` | array | 是 | 至少 1 个子 Agent。 |
|
||||
|
||||
#### user_context / billing_context / agent_runtime
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `user_context.user_id` | string | 建议 | Manager 业务用户 ID,来自登录接口 `id` 或 JWT `sub`。 |
|
||||
| `user_context.channel_id` | string | 建议 | 用于关联 NewAPI 用户、Token、Group、余额或额度策略。 |
|
||||
| `billing_context.provider` | enum | 条件 | 当前只允许 `newapi`。设置后必须提供 `channel_id`、`newapi_user_ref`、`newapi_group` 或 `quota_ref` 之一。 |
|
||||
| `billing_context.newapi_user_ref` | string | 否 | NewAPI 用户映射引用,不是 NewAPI key。 |
|
||||
| `billing_context.newapi_group` | string | 否 | NewAPI Group 映射,用于额度或策略选择。 |
|
||||
| `billing_context.quota_ref` | string | 否 | Token 或 Group 额度引用,不得包含真实 Token 原文。 |
|
||||
| `agent_runtime.platform` | enum | 条件 | 当前只允许 `agent`。 |
|
||||
| `agent_runtime.agents[].role` | string | 条件 | 必须匹配 `agents[].role_template`。 |
|
||||
| `agent_runtime.agents[].model_ref` | string | 条件 | Agent 平台模型 profile 引用;不是 NewAPI 扣费字段。 |
|
||||
| `agent_runtime.agents[].instance_count` | int | 条件 | 子 Agent 实例数量,必须大于 0。 |
|
||||
|
||||
#### agents[]
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `role_template` | string | 是 | 平台角色模板,如 `builder`/`reviewer`/`operator`。 |
|
||||
| `goal` | string | 是 | 此 agent 的任务目标。 |
|
||||
| `default_model_id` | string | 否 | 默认模型;若设置需满足 allowed_model_ids。 |
|
||||
| `sk_sources` | array | 否 | SK 来源,平台应解析为只读快照。 |
|
||||
| `runtime_execution` | object | 否 | 运行环境绑定;任一字段存在时 `profile_id` 必填。 |
|
||||
| `sk_access_policy` | object | 否 | SK 权限策略。若 `deny_skill_ids` 非空,需 `policy_ref` 或继承默认策略。 |
|
||||
| `resource_grants` | array | 否 | Manager 下发给子 Agent 的最小权限资源授权。 |
|
||||
|
||||
#### sk_sources[]
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `type` | enum | 否 | 空值表示不启用;支持 `git`、`upload`。 |
|
||||
| `artifact_id` | string | upload 必填 | 上传型 SK 包 ID。 |
|
||||
| `mime` | string | 否 | 内容类型,如 `text/markdown`。 |
|
||||
| `repo_ref.connection_id` | string | git 建议 | Git 连接资源 ID。 |
|
||||
| `repo_ref.repo_url` | string | git 建议 | 仓库 URL;不得带用户名密码。 |
|
||||
| `repo_ref.ref` | string | git 必填 | 分支/标签/commit。 |
|
||||
| `repo_ref.paths` | string[] | git 必填 | 允许读取的路径。 |
|
||||
|
||||
#### runtime_execution
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `profile_id` | string | 条件必填 | Agent 平台运行规格,如 AKS profile。 |
|
||||
| `cloud_principal_refs` | string[] | 否 | 运行身份引用,不是明文凭据。 |
|
||||
| `network_policy_ref` | string | 否 | 网络策略引用,用于限制出站/入站。 |
|
||||
|
||||
#### resource_grants[]
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|---|---|---:|---|
|
||||
| `grant_id` | string | 是 | 授权记录 ID。 |
|
||||
| `resource_id` | string | 是 | Manager 资源 ID。 |
|
||||
| `resource_type` | enum | 是 | `git` / `sk` / `project_doc` / `cloud_account` / `cloud_resource`。 |
|
||||
| `user_id` | string | 建议 | 与 `user_context.user_id` 一致;为空时平台可从部署上下文推导。 |
|
||||
| `binding_scope` | string | 是 | Git/SK/云资源作用域,例如 repo/ref/path 或云资源引用。 |
|
||||
| `tenant_id` | string | 否 | 旧兼容字段;新实现不应依赖。 |
|
||||
| `project_id` | string | 否 | 旧兼容字段;新实现不应依赖。 |
|
||||
| `target_role` | string | 是 | 必须等于当前 agent 的 `role_template`。 |
|
||||
| `target_agent_ref` | string | 是 | Manager 侧对子 Agent 的逻辑引用。 |
|
||||
| `permission_scope` | string[] | 是 | 最小权限列表,如 `repo:read`、`doc:read`。 |
|
||||
| `constraints` | object<string,string> | 否 | 路径、分支、区域、超时等限制;不得含密钥字段。 |
|
||||
| `metadata` | object<string,string> | 否 | 资源展示/审计元数据;不得含密钥字段。 |
|
||||
| `status` | enum | 是 | `pending` / `active` / `disabled` / `revoked`。 |
|
||||
| `secret_ref` | string | 条件必填 | `git`、`sk`、`cloud_account`、`cloud_resource` 必填;`project_doc` 可为空。 |
|
||||
| `audit` | object<string,string> | 否 | 审计上下文;不得含密钥字段。 |
|
||||
|
||||
### 2.4 典型场景:Agent 辅助 NewAPI 重建/部署
|
||||
|
||||
当 Manager 需要让 Agent 平台协助重建或部署 NewAPI 时,仍使用 `POST /api/agent/deployments`,但必须把任务表达为受控运维部署,不得把 VM、PostgreSQL、Redis、NewAPI key 等真实凭据写入请求体。
|
||||
|
||||
请求体示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"orchestration_plan": {
|
||||
"intent_id": "intent_newapi_rebuild_20260503_001",
|
||||
"template_hint": "newapi-rebuild-deploy",
|
||||
"objective": "在批准窗口内重建 NewAPI 服务并回传健康检查、日志与资源使用摘要",
|
||||
"risk_level": "high",
|
||||
"budget": {
|
||||
"max_tokens": 80000,
|
||||
"max_cost_usd": 20,
|
||||
"max_duration_sec": 3600
|
||||
},
|
||||
"constraints": {
|
||||
"allowed_model_ids": ["gpt-5.4", "gpt-5.4-mini"],
|
||||
"requires_approval": "true",
|
||||
"rollback_required": "true",
|
||||
"healthcheck_url_ref": "env://NEWAPI_HEALTHCHECK_URL"
|
||||
},
|
||||
"metadata": {
|
||||
"tenant_id": "legacy-user-scope",
|
||||
"project_id": "legacy-newapi-scope",
|
||||
"correlation_id": "corr_newapi_20260503_001",
|
||||
"service": "new-api",
|
||||
"environment": "production",
|
||||
"commit": "origin/main",
|
||||
"runbook_ref": "heicode/docs/deploy/new-api-rebuild-deploy-runbook.md"
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"role_template": "operator",
|
||||
"goal": "按 runbook 执行构建、服务重启、健康检查和失败回滚;只通过 secret_ref/env 获取凭据",
|
||||
"default_model_id": "gpt-5.4",
|
||||
"runtime_execution": {
|
||||
"profile_id": "aks-codex-ops",
|
||||
"cloud_principal_refs": ["principal://users/user_123/agent-ops"],
|
||||
"network_policy_ref": "netpol://bindings/newapi-prod/ops-egress"
|
||||
},
|
||||
"resource_grants": [
|
||||
{
|
||||
"grant_id": "grant_newapi_vm_ops",
|
||||
"resource_id": "res_newapi_vm",
|
||||
"resource_type": "cloud_resource",
|
||||
"user_id": "user_123",
|
||||
"binding_scope": "newapi-prod",
|
||||
"tenant_id": "legacy-user-scope",
|
||||
"project_id": "legacy-newapi-scope",
|
||||
"target_role": "operator",
|
||||
"target_agent_ref": "agent-operator-1",
|
||||
"permission_scope": ["vm:ssh:approved-window", "service:restart", "log:read", "healthcheck:read"],
|
||||
"constraints": {
|
||||
"approval_id": "approval_newapi_001",
|
||||
"window": "2026-05-03T10:00:00Z/2026-05-03T11:00:00Z",
|
||||
"rollback_command_ref": "runbook://newapi/rollback"
|
||||
},
|
||||
"metadata": {
|
||||
"host_ref": "env://NEWAPI_VM_HOST",
|
||||
"service_name": "new-api"
|
||||
},
|
||||
"status": "active",
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/user-123-newapi-prod-res-newapi-vm",
|
||||
"audit": {
|
||||
"created_by": "manager",
|
||||
"approval_id": "approval_newapi_001"
|
||||
}
|
||||
},
|
||||
{
|
||||
"grant_id": "grant_newapi_runtime_env",
|
||||
"resource_id": "res_newapi_runtime_env",
|
||||
"resource_type": "cloud_resource",
|
||||
"user_id": "user_123",
|
||||
"binding_scope": "newapi-prod",
|
||||
"tenant_id": "legacy-user-scope",
|
||||
"project_id": "legacy-newapi-scope",
|
||||
"target_role": "operator",
|
||||
"target_agent_ref": "agent-operator-1",
|
||||
"permission_scope": ["env:read:runtime", "secret:read:scoped"],
|
||||
"constraints": {
|
||||
"allowed_env_refs": "DATABASE_DSN,REDIS_URL,NEWAPI_SERVICE_TOKEN",
|
||||
"plaintext_export_forbidden": "true"
|
||||
},
|
||||
"metadata": {
|
||||
"secret_provider": "azure_key_vault",
|
||||
"scope": "newapi-runtime"
|
||||
},
|
||||
"status": "active",
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/user-123-newapi-prod-res-newapi-runtime-env",
|
||||
"audit": {
|
||||
"created_by": "manager",
|
||||
"approval_id": "approval_newapi_001"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Agent 平台返回的部署详情、日志、监控和审计中应至少能证明:构建版本/commit、服务重启结果、健康检查结果、资源使用情况、失败回滚状态。未执行真实 SSH/生产动作时,只能返回 `phase=planned` 或 `phase=pending_approval`。
|
||||
|
||||
NewAPI 重建/部署的完成判定必须同时满足:
|
||||
|
||||
1. `metadata.commit` 或部署详情中的 resolved commit 已在 VM 仓库中生效。
|
||||
2. 部署日志包含构建/compose/restart 的脱敏摘要。
|
||||
3. 健康检查返回成功,且监控接口能返回本次 deployment 的状态或资源摘要。
|
||||
4. 审计日志包含高风险审批 ID、执行者、目标环境和结果。
|
||||
5. 回滚指针或回滚命令引用已记录。
|
||||
|
||||
### 2.5 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deployment_id": "dep_abc123",
|
||||
"status": "accepted",
|
||||
"agent_instances": [
|
||||
{
|
||||
"instance_id": "agi_abc123",
|
||||
"role": "builder",
|
||||
"phase": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 部署状态与控制接口
|
||||
|
||||
### 3.1 查询部署列表
|
||||
|
||||
```http
|
||||
GET /api/agent/deployments?user_id=user_123&binding_scope=repo_default
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"deployment_id": "dep_abc123",
|
||||
"status": "accepted",
|
||||
"phase": "pending",
|
||||
"created_at": "2026-05-02T00:00:00Z",
|
||||
"updated_at": "2026-05-02T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 查询单个部署
|
||||
|
||||
```http
|
||||
GET /api/agent/deployments/{deployment_id}
|
||||
```
|
||||
|
||||
返回应包含部署状态、phase、agent_instances、最近错误、资源授权摘要和预算消耗摘要。Agent 平台返回时必须对 `secret_ref` 以外的凭据信息做脱敏;原则上不返回任何明文凭据。
|
||||
|
||||
### 3.3 停止部署
|
||||
|
||||
```http
|
||||
POST /api/agent/deployments/{deployment_id}/stop
|
||||
```
|
||||
|
||||
请求体可为空;如需原因可扩展:
|
||||
|
||||
```json
|
||||
{
|
||||
"reason": "user_requested",
|
||||
"requested_by": "manager"
|
||||
}
|
||||
```
|
||||
|
||||
成功:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deployment_id": "dep_abc123",
|
||||
"status": "stopped"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
错误与幂等:
|
||||
|
||||
| HTTP | business code | 说明 |
|
||||
|---:|---|---|
|
||||
| 200 | - | 已停止的部署重复停止也可返回 200,并保持 `status=stopped`。 |
|
||||
| 404 | `NOT_FOUND` | 部署不存在或不属于当前租户/项目。 |
|
||||
| 409 | `DEPLOYMENT_CONFLICT` | 部署已进入不可停止的终态,如 `completed` 且无运行实例。 |
|
||||
| 422 | `POLICY_REJECTED` | 高风险停止缺少审批记录或 reason 不合法。 |
|
||||
|
||||
若停止请求触发异步回收,平台可返回 `status=stopping`;Manager 应继续通过事件、日志和监控接口确认最终状态。
|
||||
|
||||
---
|
||||
|
||||
## 4. 日志接口(Manager 拉取 Agent 平台)
|
||||
|
||||
### 4.1 获取部署日志
|
||||
|
||||
```http
|
||||
GET /api/agent/deployments/{deployment_id}/logs?agent_instance_id=agi_abc123&stream=stdout&since=2026-05-02T00:00:00Z&limit=200&cursor=cur_001
|
||||
```
|
||||
|
||||
Query:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---:|---|
|
||||
| `agent_instance_id` | 否 | 不传则返回该 deployment 下全部实例日志。 |
|
||||
| `stream` | 否 | `stdout` / `stderr` / `system` / `audit`,默认全部。 |
|
||||
| `since` | 否 | RFC3339 时间,增量拉取起点。 |
|
||||
| `limit` | 否 | 默认 200,建议最大 1000。 |
|
||||
| `cursor` | 否 | 分页游标。 |
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"log_id": "log_001",
|
||||
"deployment_id": "dep_abc123",
|
||||
"agent_instance_id": "agi_abc123",
|
||||
"stream": "stdout",
|
||||
"level": "info",
|
||||
"message": "task started",
|
||||
"redacted": true,
|
||||
"occurred_at": "2026-05-02T00:00:01Z"
|
||||
}
|
||||
],
|
||||
"next_cursor": "cur_002",
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
日志要求:
|
||||
|
||||
- Agent 平台必须在返回前完成密钥脱敏。
|
||||
- `message` 不得包含密码、Token、私钥、连接串、云访问密钥。
|
||||
- Manager 只保存必要摘要和审计索引;长日志建议落对象存储并设置保留期。
|
||||
|
||||
### 4.2 实时日志流(可选)
|
||||
|
||||
```http
|
||||
GET /api/agent/deployments/{deployment_id}/logs/stream?agent_instance_id=agi_abc123
|
||||
Accept: text/event-stream
|
||||
```
|
||||
|
||||
SSE event 示例:
|
||||
|
||||
```text
|
||||
event: log
|
||||
data: {"log_id":"log_002","level":"info","message":"step completed","occurred_at":"2026-05-02T00:00:02Z"}
|
||||
```
|
||||
|
||||
SSE 事件类型:
|
||||
|
||||
| event | 说明 |
|
||||
|---|---|
|
||||
| `log` | 普通日志行,必须已脱敏。 |
|
||||
| `heartbeat` | 保活事件,建议 15-30 秒一次。 |
|
||||
| `error` | 流式读取错误;不包含敏感上下文。 |
|
||||
| `done` | 部署进入终态或服务端主动结束流。 |
|
||||
|
||||
错误处理:
|
||||
|
||||
| HTTP | business code | Manager 处理建议 |
|
||||
|---:|---|---|
|
||||
| 401/403 | `UNAUTHORIZED` / `FORBIDDEN_SCOPE` | 立即断开流并记录审计。 |
|
||||
| 404 | `NOT_FOUND` | 停止订阅并刷新部署详情。 |
|
||||
| 429 | `RATE_LIMITED` | 退避后重连,保留 `Last-Event-Id`。 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 监控接口(Manager 拉取 Agent 平台)
|
||||
|
||||
### 5.1 项目监控快照
|
||||
|
||||
```http
|
||||
GET /api/agent/projects/{binding_scope}/dashboard-snapshot?window=1h
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"project_id": "project-a",
|
||||
"active_instances": 3,
|
||||
"phase_distribution": {
|
||||
"pending": 1,
|
||||
"running": 2,
|
||||
"stopped": 0,
|
||||
"failed": 0
|
||||
},
|
||||
"failure_rate_1h": 0.02,
|
||||
"avg_task_duration": 185.3,
|
||||
"budget": {
|
||||
"tokens_used": 32000,
|
||||
"cost_usd": 4.21,
|
||||
"duration_sec": 930
|
||||
},
|
||||
"resource_usage": {
|
||||
"cpu_millicores": 1200,
|
||||
"memory_mb": 2048,
|
||||
"network_rx_bytes": 102400,
|
||||
"network_tx_bytes": 204800
|
||||
},
|
||||
"updated_at": "2026-05-02T00:05:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 单部署监控快照(建议平台实现)
|
||||
|
||||
```http
|
||||
GET /api/agent/deployments/{deployment_id}/metrics?window=15m&step=60s
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deployment_id": "dep_abc123",
|
||||
"window": "15m",
|
||||
"series": [
|
||||
{
|
||||
"metric": "tokens_used",
|
||||
"unit": "count",
|
||||
"points": [["2026-05-02T00:00:00Z", 1200]]
|
||||
},
|
||||
{
|
||||
"metric": "cpu_millicores",
|
||||
"unit": "millicore",
|
||||
"points": [["2026-05-02T00:00:00Z", 500]]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议指标:`tokens_used`、`cost_usd`、`duration_sec`、`cpu_millicores`、`memory_mb`、`restart_count`、`tool_call_count`、`error_count`、`queue_latency_ms`。
|
||||
|
||||
---
|
||||
|
||||
## 6. 事件与审计接口
|
||||
|
||||
### 6.1 部署事件
|
||||
|
||||
```http
|
||||
GET /api/agent/deployments/{deployment_id}/events?since=2026-05-02T00:00:00Z&limit=200&cursor=cur_001
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"event_id": "evt_001",
|
||||
"event": "deployment.accepted",
|
||||
"schema_version": 1,
|
||||
"user_id": "user_123",
|
||||
"channel_id": "channel_abc",
|
||||
"binding_scope": "repo_default",
|
||||
"deployment_id": "dep_abc123",
|
||||
"correlation_id": "corr_20260502_001",
|
||||
"occurred_at": "2026-05-02T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"next_cursor": "cur_002",
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
常用事件名:
|
||||
|
||||
| event | 说明 |
|
||||
|---|---|
|
||||
| `deployment.accepted` | 平台接受部署请求。 |
|
||||
| `instance.phase_changed` | 子 Agent phase 变化。 |
|
||||
| `sk_snapshot_refreshed` | SK 快照解析/刷新完成。 |
|
||||
| `resource_grant.attached` | 资源授权已绑定到实例。 |
|
||||
| `resource_grant.revoked` | 授权被撤销或禁用。 |
|
||||
| `budget.threshold_reached` | 预算阈值触发。 |
|
||||
| `deployment.failed` | 部署失败。 |
|
||||
|
||||
### 6.2 审计日志
|
||||
|
||||
```http
|
||||
GET /api/agent/audit-logs?user_id=user_123&binding_scope=repo_default&actor=agent_control_plane&action=deployment.accepted&since=2026-05-02T00:00:00Z&limit=200&cursor=cur_001
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"audit_id": "aud_001",
|
||||
"actor": "agent_control_plane",
|
||||
"action": "deployment.accepted",
|
||||
"resource": "dep_abc123",
|
||||
"user_id": "user_123",
|
||||
"channel_id": "channel_abc",
|
||||
"binding_scope": "repo_default",
|
||||
"request_id": "req_001",
|
||||
"correlation_id": "corr_20260502_001",
|
||||
"result": "ok",
|
||||
"occurred_at": "2026-05-02T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"next_cursor": "cur_002",
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. SK 快照接口
|
||||
|
||||
### 7.1 触发解析/刷新
|
||||
|
||||
```http
|
||||
POST /api/agent/sk-snapshots/resolve
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"deployment_id": "dep_abc123"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deployment_id": "dep_abc123",
|
||||
"items": [
|
||||
{
|
||||
"snapshot_id": "sks_001",
|
||||
"deployment_id": "dep_abc123",
|
||||
"user_id": "user_123",
|
||||
"binding_scope": "repo_default",
|
||||
"source_type": "git",
|
||||
"source_ref": "main:skills/heicode/**@sha_xxx",
|
||||
"resolved_at": "2026-05-02T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 查询部署 SK 快照
|
||||
|
||||
```http
|
||||
GET /api/agent/deployments/{deployment_id}/sk-snapshots
|
||||
```
|
||||
|
||||
Query:
|
||||
|
||||
| 参数 | 必填 | 说明 |
|
||||
|---|---:|---|
|
||||
| `user_id` | 建议 | 与 `X-User-Id` 或 deployment 的 `user_context.user_id` 一致。 |
|
||||
| `binding_scope` | 建议 | 与 `X-Binding-Scope` 或 deployment 的资源授权作用域一致。 |
|
||||
| `source_type` | 否 | `git` / `upload`,用于筛选。 |
|
||||
| `limit` | 否 | 默认 100,最大 500。 |
|
||||
| `cursor` | 否 | 分页游标。 |
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"snapshot_id": "sks_001",
|
||||
"deployment_id": "dep_abc123",
|
||||
"user_id": "user_123",
|
||||
"binding_scope": "repo_default",
|
||||
"source_type": "git",
|
||||
"source_ref": "main:skills/heicode/**@sha_xxx",
|
||||
"artifact_ref": "artifact://bindings/repo_default/sk/sks_001",
|
||||
"checksum": "sha256:example-redacted",
|
||||
"status": "ready",
|
||||
"resolved_at": "2026-05-02T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"next_cursor": "cur_002",
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
错误处理:
|
||||
|
||||
| HTTP | business code | 说明 |
|
||||
|---:|---|---|
|
||||
| 404 | `NOT_FOUND` | deployment 不存在或不属于当前租户/项目。 |
|
||||
| 410 | `CURSOR_EXPIRED` | cursor 过期,使用不带 cursor 的查询重拉。 |
|
||||
| 422 | `SK_SOURCE_UNRESOLVABLE` | 快照来源不可解析,详情在事件/日志中查看。 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 安全校验清单
|
||||
|
||||
Manager 发给 Agent 平台前必须执行:
|
||||
|
||||
1. `user_id`、`binding_scope`、`target_role` 与部署计划一致。
|
||||
2. 凭据型资源只传 `secret_ref`,不传明文凭据。
|
||||
3. `metadata`、`constraints`、`audit` 的 key 中不得出现 `password`、`token`、`secret`、`private_key`、`access_key`、`credential` 等敏感词。
|
||||
4. `repo_url` 不得包含用户名、密码或访问 Token。
|
||||
5. `permission_scope` 使用最小权限;生产写操作必须携带客户端审批记录,Agent 平台不得自行补批。
|
||||
6. 高风险操作(生产部署、云资源修改、删除、扩容)必须设置 `risk_level=high`,Agent 平台执行前只校验客户端审批证据。
|
||||
7. 所有日志/事件/审计返回给 Manager 前必须脱敏。
|
||||
|
||||
Agent 平台不承担高危操作审批主体。审批只发生在客户端;Agent 平台只能在执行前校验以下字段和策略是否一致:
|
||||
|
||||
| 校验项 | 要求 |
|
||||
|---|---|
|
||||
| `approval_id` | 必须存在于高危任务的 `constraints` 或 `audit`,并可追溯到客户端审批记录。 |
|
||||
| 审批主体 | 审批用户必须与 `user_context.user_id`、`resource_grants[].user_id` 或授权代理主体一致。 |
|
||||
| 审批范围 | 审批范围必须覆盖 `binding_scope`、`permission_scope`、目标环境、资源 ID 和操作类型。 |
|
||||
| TTL / 时间窗口 | 审批记录必须未过期;若使用 `window` 或 TTL,当前执行时间必须落在允许范围内。 |
|
||||
| `risk_level` | 高危资源写入、生产部署、云资源修改、删除和扩容必须为 `high`。 |
|
||||
| 策略 | 平台 policy、Key Vault 访问策略、Kubernetes/Workload Identity、网络策略和最小权限约束均必须允许本次动作。 |
|
||||
|
||||
任一校验不通过时,Agent 平台应返回 `POLICY_REJECTED` 或 `FORBIDDEN_SCOPE`,不得发起额外批准流程。
|
||||
|
||||
### 8.1 字段级约束速查
|
||||
|
||||
| 对象/接口 | 必填最小集合 | 禁止内容 |
|
||||
|---|---|---|
|
||||
| `orchestration_plan` | `intent_id`、`template_hint`、`objective`、`risk_level`、`budget`、`user_context`、`billing_context`、`agent_runtime`、`metadata.correlation_id`、`agents[]` | 密钥、连接串、真实主机登录密码、NewAPI key 原文。 |
|
||||
| `agents[]` | `role_template`、`goal` | 让子 Agent 绕过 Manager/Agent 审计的指令。 |
|
||||
| `runtime_execution` | 任一字段存在时 `profile_id` 必填 | 明文 kubeconfig、SSH key、云访问密钥。 |
|
||||
| `resource_grants[]` | `grant_id`、`resource_id`、`resource_type`、`user_id`、`binding_scope`、`target_role`、`target_agent_ref`、`permission_scope`、`status` | 明文 `password`、`token`、`private_key`、`access_key`、`credential`、数据库 DSN。 |
|
||||
| 日志/事件/审计返回 | `request_id` 或 `correlation_id`,以及发生时间 | 未脱敏命令行、环境变量 dump、密钥片段。 |
|
||||
| NewAPI 重建/部署场景 | `approval_id`、`rollback_command_ref`、健康检查引用、运行环境 `secret_ref` | 真实 VM 密码、PostgreSQL/Redis 连接串、NewAPI 服务令牌。 |
|
||||
|
||||
### 8.2 联调验收清单
|
||||
|
||||
- 创建部署请求只包含 `secret_ref`/`env://`/`runbook://` 引用,不包含真实凭据。
|
||||
- `user_id` 与 `binding_scope` 在 user context、resource grant、事件和审计中一致。
|
||||
- `risk_level=high` 的生产运维任务包含 `approval_id` 和回滚引用。
|
||||
- 日志、事件、监控、审计接口都能通过 `correlation_id` 串联。
|
||||
- NewAPI 重建/部署只在实际执行并通过健康检查后标记为已部署;未执行时状态只能是 `planned`、`pending_approval`、`accepted` 或 `running`。
|
||||
- Manager 本地 `/api/agent/*` 占位端点通过 payload 校验不等于生产 Agent 平台已上线。
|
||||
|
||||
---
|
||||
|
||||
## 9. Manager 侧当前实现映射
|
||||
|
||||
当前代码中可用于对齐/验证 payload 的 Manager 侧端点:
|
||||
|
||||
| Manager 路由 | 用途 |
|
||||
|---|---|
|
||||
| `POST /api/agent/deployments` | 校验并接受 orchestration_plan。 |
|
||||
| `GET /api/agent/deployments` | 按 user/binding scope 查询部署。 |
|
||||
| `GET /api/agent/deployments/:deployment_id` | 查询部署详情。 |
|
||||
| `POST /api/agent/deployments/:deployment_id/stop` | 停止部署。 |
|
||||
| `GET /api/agent/deployments/:deployment_id/logs` | 查询脱敏日志占位/联调日志。 |
|
||||
| `GET /api/agent/deployments/:deployment_id/metrics` | 查询单部署指标占位/联调指标。 |
|
||||
| `GET /api/agent/deployments/:deployment_id/events` | 查询事件。 |
|
||||
| `POST /api/agent/sk-snapshots/resolve` | 解析 SK 快照。 |
|
||||
| `GET /api/agent/deployments/:deployment_id/sk-snapshots` | 查询 SK 快照。 |
|
||||
| `GET /api/agent/projects/:project_id/dashboard-snapshot` | 资源作用域监控快照;路径名保留旧兼容。 |
|
||||
| `GET /api/agent/audit-logs` | 审计日志。 |
|
||||
|
||||
生产对接时,Manager 应把相同契约的请求发送给 Agent 平台;本地 Manager 端点仅作为最小验证与控制面占位,不代表所有日志/监控平台能力已完整实现。
|
||||
|
||||
当前本地 `logs` 与 `metrics` 端点只返回脱敏占位/联调数据,用于验证 Manager ↔ Agent payload、路由和验收流程。生产级实时日志流 `GET /api/agent/deployments/{deployment_id}/logs/stream` 仍属于 Agent 平台能力;Manager 不得把“本地占位通过”误报为“生产日志/监控已上线”。
|
||||
@@ -120,29 +120,6 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
|
||||
|
||||
> 客户端只需 `models`(当前可用 `gpt-5.4`);`modes` 是旧任务模式,新模型已无意义。
|
||||
|
||||
> ⚠️ **能力发现 vs 登录用户模型列表**:`/api/heicode/capabilities` 是**免登录**的总目录(渲染模型选择用)。**登录后客户端展示的「我能用哪些模型」必须走 §3.1 `/api/heicode/available-models`**——它按当前用户的分组/订阅服务端收口,且**只有** HM 这一个来源:客户端不得使用本地 preset,也不得从 CodeGW 渠道后台读取模型。
|
||||
|
||||
---
|
||||
|
||||
## 3.1 登录用户可用模型 🟢(模型列表收口)
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/heicode/available-models` | `UserOrV2DeviceAuth`(会话/JWT 或设备签名) | 当前登录用户**实际可用**的模型列表;服务端按用户可用分组 → 分组启用模型解析 |
|
||||
|
||||
```json
|
||||
{ "success": true, "data": {
|
||||
"available_models": [
|
||||
{ "model_id": "gpt-5.4", "display_name": "gpt-5.4", "default": true }
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
- 字段仅 `model_id` / `display_name` / `default`(默认模型)。
|
||||
- **禁止暴露字段**:`channel_id`、`base_url`、`api_key`、供应商类型、价格/倍率等一律不返回。
|
||||
- **唯一模型来源**:客户端登录后模型列表完全来自此接口,不使用本地 preset / 不读 CodeGW 渠道后台。
|
||||
- 鉴权要求登录用户上下文(`id>0`);未登录返回 `authentication required`。
|
||||
|
||||
---
|
||||
|
||||
## 4. Agent 模板列表 🟢
|
||||
@@ -169,66 +146,6 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
|
||||
|
||||
---
|
||||
|
||||
## 4.1 启动前预检 / 执行摘要 🟢(preflight)
|
||||
|
||||
部署 agent 前,给用户看一份「执行摘要」:用哪些资源、还缺什么、有哪些高危操作、预算上限。用户**只确认摘要**,不必面对完整参数。
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/heicode/preflight?template_id=&binding_ids=1,2,3` | `UserOrV2DeviceAuth` | 返回缺失项 + 可读执行摘要 + `version` |
|
||||
| POST | `/api/heicode/preflight/confirm` | `UserOrV2DeviceAuth` | 确认摘要 → 记审计 + 返回防篡改 `version`(#41) |
|
||||
|
||||
- `binding_ids` 同部署入参(逗号分隔或重复 key,可空)。
|
||||
|
||||
```json
|
||||
{ "success": true, "data": {
|
||||
"template_id": "architect",
|
||||
"agent_role": { "template_id":"architect", "name":"架构顾问", "model":"opus" },
|
||||
"resources": [
|
||||
{ "binding_id":17, "type":"git", "provider":"github", "name":"my-repo", "status":"active", "has_secret":true }
|
||||
],
|
||||
"invalid_bindings": [],
|
||||
"missing": [
|
||||
{ "kind":"sk", "reason":"未绑定SK 资源包" },
|
||||
{ "kind":"budget", "reason":"账户可用额度不足,请充值或开通订阅" }
|
||||
],
|
||||
"high_risk_ops": [
|
||||
{ "op":"production_deploy", "label":"生产部署 / 代码改动", "requires_approval":true },
|
||||
{ "op":"large_budget", "label":"大额预算消耗", "requires_approval":true }
|
||||
],
|
||||
"budget": { "remaining_quota":1234567, "quota_per_unit":500000, "tier_max_agents":5, "current_agents":1 },
|
||||
"approval_policy": { "mode":"per_high_risk_op" },
|
||||
"ready": false,
|
||||
"version": "pfv1_3a9c…"
|
||||
}}
|
||||
```
|
||||
|
||||
- **`missing`**:必需类别(`git`/`sk`/`project_document`/`cloud_account`)未绑定、`budget`(余额≤0)、`agent_slot`(在跑数已达 tier 上限)。`ready=true` 当且仅当 `missing` 为空。
|
||||
- **`high_risk_ops`**:固定 enum —— `production_deploy` / `db_write` / `cloud_resource_delete` / `production_secret` / `large_budget`;由已绑资源类型推导,均 `requires_approval`。
|
||||
- **红线**:`resources` 只暴露 `type/provider/name/status/has_secret`(布尔),**绝不返回 `secret_ref`/`channelId`/`base_url`/价格**。
|
||||
- **`invalid_bindings`**:请求里无效 / 非本人 / 非 active 的绑定 id(不阻断,供前端提示)。
|
||||
- **`version`**:防篡改摘要版本(#41),由**稳定安全面**派生(template + 资源 + 高危 + 必需缺失项);**不含**易变的预算数字,故余额波动不会改版本。
|
||||
|
||||
### 4.1.1 确认 + 防篡改版本(#41)
|
||||
|
||||
`POST /api/heicode/preflight/confirm` body:`{ "template_id":"architect", "binding_ids":[17] }`
|
||||
|
||||
```json
|
||||
{ "success": true, "data": {
|
||||
"version": "pfv1_3a9c…", // 把它带到部署请求
|
||||
"template_id": "architect",
|
||||
"summary": { …同上执行摘要… },
|
||||
"confirmed": true
|
||||
}}
|
||||
```
|
||||
|
||||
- 仅当 `ready=true` 才能确认;否则返回 `POLICY_REJECTED`(先补齐缺失项)。
|
||||
- 确认会**持久化一条强一致的确认记录**(user+template+version,默认 TTL `HEICODE_PREFLIGHT_CONFIRMATION_TTL_SECONDS`=3600s),并附带写一条审计事件 `preflight.confirmed`。`version` 的派生**已纳入模板安全面**(definition/model/name)——管理员改了同一模板的 definition/model,旧确认即失效。
|
||||
- **部署校验**:`POST /api/heicode/agents` 可带 `preflight_version`。HM 校验三件事:① 用**当前**资源/模板状态重算版本 == 传入(资源增删/改类型/改凭证、模板变更 → 不匹配即拒);② 当前摘要仍 `ready`(预算/agent_slot 等易变项重查,防 confirm 后余额耗尽仍启动);③ **存在该版本的未过期确认记录**(杜绝直接拿 GET 的 `version` 绕过 confirm)。
|
||||
- 默认**仅在带了 `preflight_version` 时校验**(向后兼容,不带照常部署);设 `HEICODE_PREFLIGHT_REQUIRED=true` 则**强制**要求先 confirm。
|
||||
|
||||
---
|
||||
|
||||
## 5. 我的 Agent 🟢(已对接 AM,生产验证)
|
||||
|
||||
> **2026-06-04 生产实测**:部署 / 列表 / 详情 / 状态 / 删除均真实可用。
|
||||
@@ -260,12 +177,7 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
|
||||
"status": "Pending", // Pending | running | failed | stopped …
|
||||
"runtime_id": "dep-4bb07dc1e376", // AM 侧运行时 id
|
||||
"created_at": "2026-06-04T09:04:15Z",
|
||||
"updated_at": "2026-06-04T09:04:15Z",
|
||||
"security": { // ★ #55 A2A 直连安全元数据
|
||||
"scheme": "http", // http | https
|
||||
"security_profile": "none", // none | tls | mtls
|
||||
"secure": false // = security_profile != none
|
||||
}
|
||||
"updated_at": "2026-06-04T09:04:15Z"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
@@ -286,77 +198,6 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
|
||||
|
||||
---
|
||||
|
||||
## 5.1 Agent 模型用量 🟢(按部署 Agent 聚合)
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/heicode/agents/{deployment_id}/usage` | `UserOrV2DeviceAuth`,且只能查**自己**的 agent | 按该 agent 的隐藏模型 token(name=`agent:<deployment_id>`)在计费 logs 中聚合用量 |
|
||||
|
||||
查询参数(可选):`start` / `end` = unix 秒时间窗(缺省=全窗口)。
|
||||
|
||||
```json
|
||||
{ "success": true, "data": {
|
||||
"agent_id": "dep_4bb07dc1e376",
|
||||
"quota": 12345, // 消耗的额度(配额单位)
|
||||
"prompt_tokens": 8000,
|
||||
"completion_tokens": 4000,
|
||||
"call_count": 12,
|
||||
"quota_per_unit": 500000, // 额度→货币换算分母(quota/quota_per_unit=美元额度)
|
||||
"budget_remaining": 1234567 // ★ 用户钱包剩余额度(预算剩余;-1=读取失败,不阻断展示)
|
||||
}}
|
||||
```
|
||||
|
||||
- **空数据语义**:无调用记录时各计数为 `0`(仍返回 `success:true`,不是 404)。
|
||||
- **`budget_remaining`(#9)**:用户剩余可用额度(同 `quota_per_unit` 口径换算)。Agent 模型调用经隐藏 token 计费到 `user.Quota`,故"本任务预算剩余"= 用户钱包剩余额度。
|
||||
- **与 billing logs 的关系**:用量来自统一计费 logs(`SumAgentUsage`),按 token name `agent:<deployment_id>` 过滤聚合 —— 即 agent 走 HM `/v1/*` 的真实消耗,与用户钱包/订阅扣费同源。
|
||||
- **计费归集语义(#30)**:每个部署的 agent,HM 为其 mint 一个**隐藏、不展示在用户 token 列表**的模型 token(`UnlimitedQuota:true`)。`UnlimitedQuota` 的含义是「**不对该 token 自身设单独的剩余额度上限**」——它**不**绕过用户额度:agent 经此 token 调 `/v1/*` 时,HM 仍先校验 `user.Quota`,并在结算时从 `user.Quota`(钱包)或订阅项扣费、写计费 log,完全经过计费表达式。停止/删除 agent 后该 token 被撤销,旧 token 无法再调 `/v1/*`。该 token 对普通用户隐藏,但审计/管理员可追踪。
|
||||
|
||||
---
|
||||
|
||||
## 5.2 Swarm 运行查询 🟢(#45/#46 · 契约 v1 已冻结)
|
||||
|
||||
多 Agent 蜂群运行(`agent_swarm` / HeiCode Swarm)的查询。读类接口数据全部来自 HM 已持久化的运行时回调(Swarm → HM 带签名回调),**无需实时调 Swarm**;契约已冻结为 `runtime-contract v1`/`event-schema v1`(`agent_swarm#14`/`#15`)。
|
||||
|
||||
| 方法 | 路径 | 说明 | 状态 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/heicode/swarms` | 列出我的 swarm 运行 | 🟢 本地数据 |
|
||||
| GET | `/api/heicode/swarms/:id` | 单个运行状态(:id = deployment_id / swarm_id / correlation_id 任一) | 🟢 |
|
||||
| GET | `/api/heicode/swarms/:id/events?after=&limit=` | 事件增量拉取(`after`=上次返回的 `next_after`,oldest-first) | 🟢 |
|
||||
| GET | `/api/heicode/swarms/:id/events/stream?after=` | **事件 SSE 实时流**(与 events 同源;进入即回放 `after` 之后历史) | 🟢 |
|
||||
| GET | `/api/heicode/swarms/:id/artifacts` | 从已存事件派生的产物 | 🟢(派生) |
|
||||
| POST | `/api/heicode/swarms/:id/stop` | 停止运行(**写**,真实调运行时) | 🟢(开关:`SWARM_RUNTIME_ENABLED=true` + base_url + service token;否则 `POLICY_REJECTED`) |
|
||||
|
||||
```json
|
||||
// GET /api/heicode/swarms/:id
|
||||
{ "success": true, "data": {
|
||||
"deployment_id":"dep_…", "swarm_id":"swarm-…", "correlation_id":"…",
|
||||
"status":"blocked", // 运行时真实状态(契约 §4)
|
||||
"display_status":"degraded", // 客户端展示态(§4.1 映射:blocked→degraded,余直通)
|
||||
"goal_summary":"…", // 单行目标(从 plan objective 提取,折叠/截断/脱敏;取不到为空串)
|
||||
"phase":"…", "runtime_state":"…", "failure_reason":"",
|
||||
"created_at":"…","updated_at":"…","runtime_last_sync_at":"…" }}
|
||||
|
||||
// GET /api/heicode/swarms/:id/events?after=120
|
||||
{ "success": true, "data": {
|
||||
"items":[ {"id":121,"sequence":42,"event_type":"task.completed","task_id":"…","result":"ok","occurred_at":"…","payload":{…}} ],
|
||||
"next_after":121, "count":1 }}
|
||||
|
||||
// GET /api/heicode/swarms/:id/artifacts (从 artifact.created 事件派生,扁平)
|
||||
{ "success": true, "data": {
|
||||
"items":[ {"event_id":"evt-…","sequence":50,"task_id":"…","uri":"azblob://…","checksum":"sha256:…","created_at":"…"} ],
|
||||
"total":1 }}
|
||||
```
|
||||
|
||||
- 状态机(契约 §4):`waiting_approval → running →(blocked ⇄ running)→ completed/failed/stopped`;客户端展示用 `display_status`(§4.1:`blocked→degraded`;`preparing`/`verifying` 是运行时 running 子态,HM 未单独存,不臆造)。
|
||||
- **事件**:`sequence` = agent_swarm event-schema v1 的 **per-swarm 严格递增序号**(每 swarm 从 1、无空洞),客户端用它去重/排序;`id`/`next_after` 是 HM 不透明分页游标(单调,兼容 `sequence` 尚未全量上线)。事件 `payload` **已脱敏**(递归剔除 `secret_ref`/`credential_ref`/`signing_secret_ref`/credentials/大字段 + `RedactText` 兜底)。查询按**当前用户**作用域。
|
||||
- **新增事件类型**(已注册):`swarm.completed/failed/stopped`、`approval.approved/rejected`、`handoff.created`(event-schema v1)。
|
||||
- **artifact**:从 `artifact.created` 派生扁平 `{uri,checksum,task_id,size_bytes?,created_at}`(**无 secret_ref**;size 未知则省略,不伪造)。
|
||||
- **SSE 实时流**(`/events/stream`):`text/event-stream`,帧 `event: message` 的 `data` 即单条事件视图(同 `events` 的 item);命中终态(`swarm.completed/failed/stopped`)发 `event: done`(`{next_after,reason}`)后收流,客户端断开或超时(30min)亦收流。与轮询 `events?after` 等价,二选一;断线后用最后的 `next_after` 重新订阅即可续传。
|
||||
- **stop**:`SWARM_RUNTIME_ENABLED=true` 且配齐 base_url + service token 时真实调用运行时(`X-Idempotency-Key` 幂等),返回 `{accepted:true,runtime_status,...}`;**终态不在响应里**——由运行时异步回推 `swarm.stopped` 事件,客户端经 `events`/`stream` 收最终态。未启用则 `POLICY_REJECTED`(不伪造受理)。
|
||||
- `stop`(写):仍 gated,待 stop 真实接入 PR(复用运行时客户端 + `SWARM_RUNTIME_SERVICE_TOKEN`,契约 §3 已冻结)落地。
|
||||
|
||||
---
|
||||
|
||||
## 6. 直连 Agent(客户端 ↔ agent,A2A 协议)
|
||||
|
||||
> 客户端**直接连 agent 子域名**(§5 的 `subdomain`),不经过 HM。agent 是 AM 的 `coding_a2a_agent`,走 **A2A 协议**。
|
||||
@@ -364,8 +205,6 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
|
||||
> ⚠️ **连之前先确认 agent 就绪**:新建后 `status=Pending`(还在拉起)。等 `GET /api/heicode/agents/{id}/status` 变 `running`、或 `GET {subdomain}/health` 返 200 再连。**2026-06-04 复测:数秒即 `running`,`/health` 200、`/message/send` 带令牌任务 `completed`,直连已通。**
|
||||
>
|
||||
> ⚠️ 当前 AM 侧两点(待加固,不影响调通):① **令牌校验尚未真正生效**——无 `X-Agent-Access-Token` 也被放行;客户端仍应规范地每请求都带,等 AM 开启校验即自动生效。② 子域名目前 `http://` 明文,令牌/`api_key` 会明文传输,等 AM 上 HTTPS。
|
||||
>
|
||||
> 🔒 **传输安全门(#55)**:agent 对象回传 `security`(`scheme` http/https、`security_profile` none/tls/mtls、`secure` 布尔)。客户端据此在生产强制 HTTPS(`HEICODE_AGENT_REQUIRE_SECURE=1`):`secure=false`(当前明文)→ 拒绝直连并提示。AM 上线 HTTPS/mTLS listener 后 `security_profile` 自动变 tls,客户端无需改包。HM 保守口径:无法确证 TLS 即标 `none`。
|
||||
|
||||
- **同步**:`POST {subdomain}/message/send`
|
||||
- **流式**:`POST {subdomain}/message/stream`(返回 `text/event-stream`)
|
||||
@@ -403,59 +242,6 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
|
||||
|
||||
---
|
||||
|
||||
## 7.1 客户端运行时配置 🟢
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/heicode/config` | 无(公开,非敏感全局配置) | 客户端 runtime config / telemetry 开关;客户端轮询以便会话内即时生效(无需重登) |
|
||||
|
||||
```json
|
||||
{ "success": true, "data": {
|
||||
"telemetry": {
|
||||
"enabled": false, // 默认关闭(kill switch)
|
||||
"endpoint": "/api/heicode/telemetry/events",
|
||||
"max_batch": 20,
|
||||
"flush_interval_sec": 30,
|
||||
"retention_days": 30 // 服务端保留期:超期遥测被清理(#32)
|
||||
}
|
||||
}}
|
||||
```
|
||||
|
||||
- 客户端必须以 `telemetry.enabled` 为准:为 `false` 时**停止上送**(摄入端点也会回 410)。
|
||||
|
||||
---
|
||||
|
||||
## 7.2 客户端错误遥测上送 🟡(默认关闭)
|
||||
|
||||
| 方法 | 路径 | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/heicode/telemetry/events` | `UserOrV2DeviceAuth` + **V2 设备签名**(需配对设备) | 上送客户端错误遥测;**诊断流量,绝不计费、不进 consume log** |
|
||||
|
||||
- **默认关闭**:`HEICODE_TELEMETRY_ENABLED=false` 时返回 **410**(kill switch),客户端应停止上送。
|
||||
- **鉴权**:需登录用户 + 已配对设备;请求头带 `X-Heicode-Device-Id`。会话-only(无设备)调用被拒(403)。
|
||||
- **Body = 顶层 JSON 数组**(不是包裹对象),**1–20 条/批**,**≤256KB**。超限 413,非数组 400。
|
||||
- **每条事件**字段(诊断用,无用户内容):`client_id`(须等于配对设备 id)、`schema_version`、`app_version`、`platform`、`os_version`、`arch`、`locale`、`error_category`、`error_code`、`error_message_hash`、`stack_hash`、`stack_top`(数组)、`context`(对象)、`timestamp`、`session_seq`。
|
||||
- **服务端脱敏**:`stack_top` / `context` 即使客户端已脱敏,服务端仍二次 redaction(剥离 `sk-`/`Bearer`/URL token/JSON 密钥字段)。
|
||||
- **context 字段白名单(#32)**:`context` 仅保留 `route` / `retryable` / `phase` / `exit_code` / `duration_ms` / `attempt`;其余键(含 email、完整文件路径、prompt、IP 原文等可识别信息)**一律丢弃**。`stack_top`/`context` 单字段脱敏后截断到 8KiB。
|
||||
- **重试语义**:4xx(校验失败/超限/kill switch 410)**丢弃不重试**;5xx(持久化失败)可重试。
|
||||
- **保留期(#32)**:服务端按 `HEICODE_TELEMETRY_RETENTION_DAYS`(默认 30)定期清理超期遥测。
|
||||
|
||||
> ⚠️ **上线前置门槛(#32)**:`device_id`/`client_id` 可关联账号,属隐私敏感。生产开启 `HEICODE_TELEMETRY_ENABLED=true` 前必须:隐私文档已如实披露「设备 ID 可关联账号的错误遥测」、产品/法务已确认、kill switch 已验证。隐私文档同步见 heicodeDocs(#34)。
|
||||
|
||||
```json
|
||||
// 请求体(顶层数组,示意一条)
|
||||
[
|
||||
{ "client_id":"<paired-device-id>", "app_version":"0.5.0", "platform":"win32",
|
||||
"error_category":"ui_crash", "error_code":"RENDERER_ERROR",
|
||||
"error_message_hash":"9f2a7c1b4e8d", "stack_hash":"a1b2c3d4e5f6",
|
||||
"stack_top":["at MessageList (MessageList.tsx:212:9)"],
|
||||
"context":{"route":"chat"}, "timestamp":"2026-06-09T07:21:33.123Z", "session_seq":1 }
|
||||
]
|
||||
// 成功:{ "success": true, "accepted": 1 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 响应 envelope 与错误码
|
||||
|
||||
**成功**:`{ "success": true, "data": {…} }`
|
||||
@@ -501,15 +287,11 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
|
||||
| 接口 | 鉴权 | 状态 |
|
||||
|---|---|---|
|
||||
| `GET /api/heicode/capabilities` | 无(公开) | 🟢 |
|
||||
| `GET /api/heicode/available-models` | 会话/设备 | 🟢(登录用户模型列表收口,唯一来源) |
|
||||
| `GET /api/heicode/config` | 无(公开) | 🟢(runtime config / telemetry 开关) |
|
||||
| `GET /api/user/self`、`/self/models` | **UserAuth(会话/JWT)** | 🟢 |
|
||||
| `GET /api/heicode/agent-templates` | 会话/设备 | 🟢(生产已验证 19 中文模板) |
|
||||
| `GET /api/heicode/agents` `/{id}` `/{id}/status` | 会话/设备 | 🟢 |
|
||||
| `POST /api/heicode/agents` | 会话/设备 | 🟢(调 AM,AM 接口 🔴) |
|
||||
| `POST /api/heicode/agents/{id}/stop`、`DELETE /{id}` | 会话/设备 | 🟢(调 AM 🔴) |
|
||||
| `GET /api/heicode/agents/{id}/usage` | 会话/设备(仅自己的 agent) | 🟢(按 `agent:<id>` token 聚合计费 logs) |
|
||||
| `POST /api/heicode/telemetry/events` | 会话/设备 + V2 设备签名 | 🟡 默认关闭(410 kill switch);不计费 |
|
||||
| 模型 `/v1/*` | 同模型调用 | 🟢 |
|
||||
| `POST /api/heicode/agent-access/verify` | 公开(令牌即凭据) | 🟢(可选兜底;接入用 ① 本地比对,默认不用此端点) |
|
||||
| 直连 agent SSE(subdomain + X-Agent-Access-Token) | agent 本地比对令牌 | 🟢 直连已通 / ⚠️ 令牌校验待 AM 开启 |
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# Heicode Manager 蜂群 Runtime 联调配置模板
|
||||
|
||||
更新时间:2026-05-27
|
||||
适用范围:Heicode Manager 调用 Agent Manager / HeiCode-Swarm Runtime。
|
||||
|
||||
## 一、标准 Agent Manager Runtime 配置
|
||||
|
||||
当 Runtime 提供正式 `POST /api/swarms` 时,Manager 建议配置:
|
||||
|
||||
```bash
|
||||
AGENT_RUNTIME_ENABLED=true
|
||||
AGENT_RUNTIME_ASYNC=false
|
||||
AGENT_RUNTIME_BASE_URL=http://<runtime-host>
|
||||
AGENT_RUNTIME_SERVICE_TOKEN=<service-token>
|
||||
AGENT_RUNTIME_HEALTH_PATH=/api/agent/health
|
||||
AGENT_RUNTIME_CREATE_PATH=/api/swarms
|
||||
AGENT_RUNTIME_STOP_PATH=/api/swarms/{swarm_id}/stop
|
||||
AGENT_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
|
||||
AGENT_RUNTIME_TIMEOUT_SECONDS=10
|
||||
HEICODE_PUBLIC_BASE_URL=https://code.xinghanlab.com
|
||||
AGENT_RUNTIME_CALLBACK_URL=https://code.xinghanlab.com/api/agent/callbacks/swarm-events
|
||||
AGENT_CALLBACK_TOKEN=<callback-token>
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
1. `AGENT_RUNTIME_SERVICE_TOKEN` 是 Manager 调 Runtime 的 Bearer token。
|
||||
2. `AGENT_CALLBACK_TOKEN` 是 Runtime 回调 Manager 时放在 `X-Agent-Service-Token` 的 token。
|
||||
3. 两个 token 可以不同,不要写进前端或日志。
|
||||
4. 如果 Runtime 返回 `swarm_id`,Manager 会保存到 `runtime_swarm_id`。
|
||||
5. 如果 Runtime 返回 `deployment_id`,Manager 会保存到 `runtime_deployment_id`。
|
||||
|
||||
## 二、当前 HeiCode-Swarm Orchestrator 兼容配置
|
||||
|
||||
如果短期只用现有 Orchestrator 的 `/health` 和 `/tasks` 先做连通性测试,可以临时配置:
|
||||
|
||||
```bash
|
||||
AGENT_RUNTIME_ENABLED=true
|
||||
AGENT_RUNTIME_ASYNC=false
|
||||
AGENT_RUNTIME_BASE_URL=http://52.139.240.116:8000
|
||||
AGENT_RUNTIME_SERVICE_TOKEN=<optional-if-supported>
|
||||
AGENT_RUNTIME_HEALTH_PATH=/health
|
||||
AGENT_RUNTIME_CREATE_PATH=/tasks
|
||||
AGENT_RUNTIME_STOP_PATH=/tasks/{runtime_deployment_id}/stop
|
||||
AGENT_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
|
||||
AGENT_RUNTIME_TIMEOUT_SECONDS=10
|
||||
HEICODE_PUBLIC_BASE_URL=https://code.xinghanlab.com
|
||||
AGENT_RUNTIME_CALLBACK_URL=https://code.xinghanlab.com/api/agent/callbacks/swarm-events
|
||||
AGENT_CALLBACK_TOKEN=<callback-token>
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
1. `/tasks` 不是最终生产契约,只能做临时兼容测试。
|
||||
2. 当前 `/tasks` 字段不足以完整表达 `resource_grants`、`secret_ref`、`approval_policy`、`budget`。
|
||||
3. 若 Orchestrator 不支持 stop 和 approval decision,Manager 会记录回传失败审计,但用户审批记录仍会保存。
|
||||
|
||||
## 三、Runtime 回调 Manager 的请求头
|
||||
|
||||
推荐使用 service token:
|
||||
|
||||
```http
|
||||
POST /api/agent/callbacks/swarm-events
|
||||
X-Agent-Service-Token: <callback-token>
|
||||
X-Agent-Event-Id: <event-id>
|
||||
X-Correlation-ID: <correlation-id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
也支持 HMAC:
|
||||
|
||||
```http
|
||||
X-Agent-Event-Id: <event-id>
|
||||
X-Agent-Timestamp: <unix-ms>
|
||||
X-Agent-Signature: sha256=<hex>
|
||||
X-Correlation-ID: <correlation-id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
签名内容:
|
||||
|
||||
```text
|
||||
timestamp + "." + event_id + "." + raw_body
|
||||
```
|
||||
|
||||
## 四、最小联调命令
|
||||
|
||||
健康检查:
|
||||
|
||||
```bash
|
||||
curl -sS "$AGENT_RUNTIME_BASE_URL$AGENT_RUNTIME_HEALTH_PATH"
|
||||
```
|
||||
|
||||
Manager 本地默认模拟链路:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "https://code.xinghanlab.com/api/agent/user/deployments/<manager-deployment-id>/simulate-events" \
|
||||
-H "Authorization: Bearer <manager-user-token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
该命令用于 Manager 自测,不代表真实 Runtime 已执行。默认模拟会落 callback、artifact、approval、timeline 记录,并用于检查页面展示和 `secret_ref` 脱敏。
|
||||
|
||||
模拟 Runtime 回调 Manager:
|
||||
|
||||
```bash
|
||||
curl -sS -X POST "https://code.xinghanlab.com/api/agent/callbacks/swarm-events" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-Service-Token: $AGENT_CALLBACK_TOKEN" \
|
||||
-H "X-Agent-Event-Id: evt-smoke-001" \
|
||||
-H "X-Correlation-ID: corr-smoke-001" \
|
||||
-d '{
|
||||
"event_id": "evt-smoke-001",
|
||||
"event_type": "timeline.updated",
|
||||
"deployment_id": "<manager-deployment-id>",
|
||||
"swarm_id": "<runtime-swarm-id>",
|
||||
"occurred_at": "2026-05-27T10:00:00Z",
|
||||
"source": "heicode-swarm-runtime",
|
||||
"payload": {
|
||||
"title": "Runtime callback received",
|
||||
"summary": "Manager callback pipeline is reachable",
|
||||
"severity": "info"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"event_id": "evt-smoke-001",
|
||||
"inserted": true,
|
||||
"deduplicated": false,
|
||||
"deployment_id": "<manager-deployment-id>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 五、上线前必须确认
|
||||
|
||||
| 项 | 标准 |
|
||||
|---|---|
|
||||
| create | Manager 能拿到 `runtime_deployment_id` 和 `runtime_swarm_id` |
|
||||
| callback | Runtime 至少回调 `deployment.status_changed`、`task.completed`、`artifact.created` |
|
||||
| approval | Runtime 发 `approval.requested`,Manager 用户审批后 Runtime 收到 decision |
|
||||
| artifact | Manager artifacts 页面能看到 artifact 类型、标题、摘要和 URI |
|
||||
| timeline | Manager timeline 能区分 `manager`、`runtime`、`simulated` 来源 |
|
||||
| secret | 请求、响应、日志、artifact metadata 中无长期明文密钥 |
|
||||
@@ -1,64 +0,0 @@
|
||||
# Heicode 蜂群(Swarm)—— 现状裁定与后续跟踪入口(HM 侧)
|
||||
|
||||
> 起草:2026-06-05 · 更新:2026-06-10(同步 `agent_swarm` 当前状态)· 状态:跟踪占位(HM 侧不实现,归口 AM / Swarm)
|
||||
>
|
||||
> 本文是 PR #15「文档大同步」删除全部旧 sub/蜂群文档后留下的**追踪入口**,回答三件事:① 旧文档为什么作废、② 蜂群能力现在归谁、③ 未来对接/待定项在哪里跟踪。删除旧文档≠放弃蜂群能力,**上下文迁移到本文**。
|
||||
>
|
||||
> **2026-06-10 勘误**:蜂群仓库名是 **`agent_swarm`**(GitHub `xmindlab-heicode/agent_swarm`;产品名 **HeiCode Swarm**),不是 `HeiCode-Swarm`。该仓已从「仅 `/tasks`」演进为完整的 Master-Agent 编排运行时并起草了正式契约 `runtime-contract.md`——本文下文已据实更新。
|
||||
|
||||
---
|
||||
|
||||
## 1. 裁定(当前结论)
|
||||
|
||||
- **HM(Heicode Manager)当前不实现 swarm runtime。** HM 的职责边界是:模型网关(`/v1/*`)+ 资源/权限/计费/审计 + **模板 Agent 部署编排**(经 AM 启动常驻 agent、客户端直连)。多 agent 蜂群编排**不在 HM 端**。
|
||||
- **旧的「HM 内部 sub/蜂群任务编排」模型已作废。** 那套(sub 任务、display_status、HM 侧蜂群 runtime 对接草案)随产品转向「模板 Agent + 客户端直连」一并下线,相关代码删除清单见 [`heicode-hm-legacy-teardown.md`](./heicode-hm-legacy-teardown.md),当前模型见 [`heicode-hm-template-agent-model.md`](./heicode-hm-template-agent-model.md)。
|
||||
- **新版蜂群能力在 `agent_swarm`(HeiCode Swarm)仓,不在本仓。** 当前模型:主控 Agent(Master Agent,`orchestrator/master_agent.py`)把需求**分解**为子任务 → **派发**给不同领域的专家 Agent **并行执行** → 重叠领域**协作/移交** → 主控**评审/重做**循环(受 `MAX_REVIEW_CYCLES` 约束)→ **汇总交付**;Orchestrator(FastAPI) + Redis 权威状态 + WebSocket Agent 协议 + Prometheus 指标。旧文档描述的「HM 主导编排蜂群 / 仅 `/tasks`」已作废。HM 侧最多提供资源/计费/鉴权支撑面,runtime 与编排由 Swarm 承载。
|
||||
- **契约已冻结为 v1(2026-06-10)。** Swarm 侧已冻结 `runtime-contract v1`(`agent_swarm#14`:stop = `POST /api/agent/swarm/deployments/{deployment_id}/stop` + `Bearer SWARM_RUNTIME_SERVICE_TOKEN` + `X-Idempotency-Key`,受理后异步回推 `swarm.stopped`;`deployment_id↔swarm_id↔manager_deployment_id` 三映射;状态 §4.1 `blocked→degraded`)与 `event-schema v1`(`agent_swarm#15`:per-swarm 严格递增 `sequence`;新增 6 类事件;`secret_ref`/`credential_ref`/`signing_secret_ref` 按设计以 `azkv://` 引用透传)。
|
||||
- **HM 侧已据冻结契约落地读侧 + stop + SSE(不再 deferred 的部分):**
|
||||
- **只读查询(#45 / PR #59)🟢**:list/status/`events?after`/artifacts 全部基于 HM 已持久化的回调事件(`agent_callback.go` → `AgentCallbackEvent`),按 `user_id` 收口、payload 递归脱敏(剔除 secret_ref/凭据/大字段 + `RedactText` 兜底),**无需调用 Swarm 运行时**。`controller/agent_swarm_query.go`。
|
||||
- **stop(#45)🟢(受开关约束)**:真实调用运行时冻结路径,复用 `agent_runtime_client` 的配置/URL/信封解析;`SWARM_RUNTIME_ENABLED=true` 且配齐 `SWARM_RUNTIME_BASE_URL` + `SWARM_RUNTIME_SERVICE_TOKEN` 时才发起,否则 `POLICY_REJECTED`,**绝不伪造 accepted**。终态不抢写,由 `swarm.stopped` 回调写回。
|
||||
- **事件 SSE 实时流(#46 读侧)🟢**:`GET /api/heicode/swarms/:id/events/stream?after=`,与 `events?after` 同源(HM 持久化事件),命中终态(`swarm.completed/failed/stopped`)/客户端断开/超时结束,**不依赖运行时**。
|
||||
- **仍 deferred / 阻塞的部分:** ① **per-user 计费令牌注入(#60)**——swarm create 目前未像模板 Agent 那样现签隐藏 `sk-` 注入运行时(`OPENAI_API_KEY`+`OPENAI_API_BASE=HM/v1`),是 HM 唯一计费缺口,**阻塞于 Swarm 对 `agent_swarm#16` 注入口径 A/B 的确认**;② 事件 **append 写入 / 主动拉取**仍走回调被动落库,未做 HM→Swarm 主动拉取(无对应需求)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 能力归属与后续跟踪入口
|
||||
|
||||
| 项 | 归属仓 / 负责人 | 说明 |
|
||||
|---|---|---|
|
||||
| Swarm runtime / 多 agent 编排 | **`agent_swarm`**(产品名 HeiCode Swarm,@Songhaoz666) | 执行面、Master-Agent 编排、回调、Swarm Runtime |
|
||||
| Manager ↔ Swarm 契约 | Swarm 侧契约**已冻结为 v1**:`runtime-contract v1`(`agent_swarm#14`)+ `event-schema v1`(`agent_swarm#15`),见 `agent_swarm/docs/integration/`;HM 侧据此对接,本文为 HM 侧锚点 | Swarm 已实现生命周期接口 create/status/tasks/logs/events/metrics/workflow/diagnostics/stop/approvals(均带 `deployment_id`,三组路径别名 `/api/swarms`、`/api/agent/swarm/deployments`、`/api/agnet/deployments`);HM 接入跟踪在 #45/#46/#60 与 PR #59 |
|
||||
| Agent 运行时(单 agent,已落地) | **`agent_management`(AM)**(@azgy) | 模板 Agent 启动/状态/停止/删除,契约见 [`heicode-am-contract.md`](./heicode-am-contract.md) |
|
||||
|
||||
> **HM 侧对接状态(2026-06-10)**:契约已按 `runtime-contract v1`/`event-schema v1` 冻结(`agent_swarm#14`/`#15`)。HM 已落地 #45(只读查询 + stop,PR #59 + 本批)与 #46 读侧(events SSE);剩余阻塞仅 #60(per-user 计费令牌注入,待 `agent_swarm#16` A/B 确认)。本文仍是「蜂群在 HM 侧」的唯一锚点——不恢复旧文档。
|
||||
|
||||
---
|
||||
|
||||
## 3. 已删除文档的迁移映射(PR #15)
|
||||
|
||||
> 对照 Fasthei 复审要求:每份删除文档标明是「作废 / 迁移 / deferred」,以及上下文去向。
|
||||
|
||||
### 蜂群 / swarm 相关(7 份,上下文迁移至本文 + Swarm 仓)
|
||||
| 删除的文档 | 处置 | 去向 |
|
||||
|---|---|---|
|
||||
| `docs/heicode-manager-sub-swarm-progress-checklist.md` | **deferred** | 旧 HM 内部 sub/蜂群编排进度;未完成事项随模型作废,新蜂群进度归 Swarm 仓跟踪 |
|
||||
| `docs/heicode-manager-swarm-gap-analysis.md` | **作废** | 针对废弃的「HM 主导蜂群」模型的 gap 分析,前提不再成立 |
|
||||
| `docs/integration/agent-manager-swarm-runtime-change-request.md` | **deferred → AM/Swarm** | 旧 HM→AM 蜂群 runtime 变更请求;如需重提,由 Swarm 侧按新契约发起 |
|
||||
| `docs/integration/AgentManager蜂群Runtime接口实现要求.md` | **deferred → AM/Swarm** | 蜂群 runtime 接口要求归 Swarm 侧实现与跟踪 |
|
||||
| `docs/integration/AgentManager蜂群Runtime联调待确认与补充要求.md` | **deferred → AM/Swarm** | 联调待确认项随新契约在 Swarm 侧重列 |
|
||||
| `docs/integration/heicode-manager-swarm-runtime-env-template.md` | **作废** | 旧蜂群 runtime env 模板,对应废弃的 HM 编排路径 |
|
||||
| `docs/integration/蜂群模式-AgentManager对接任务清单.md` | **deferred → AM/Swarm** | 对接任务清单随新契约在 Swarm 侧重建 |
|
||||
|
||||
### 其它旧文档(4 份,被新主线文档取代)
|
||||
| 删除的文档 | 处置 | 取代者 |
|
||||
|---|---|---|
|
||||
| `Heicode-Manager-项目说明与踩坑交接.md` | **作废** | 被 HM-only 化的 `README.md` / `CLAUDE.md` / `AGENTS.md` 取代 |
|
||||
| `docs/Heicode-Manager-agent统一改造落地计划.md` | **作废** | 被 `integration/heicode-hm-template-agent-model.md` 取代 |
|
||||
| `docs/heicode-manager-standalone-execution-plan.md` | **作废** | 被 `plan.md` 取代 |
|
||||
| `docs/integration/agent-platform-request-contract.md` | **作废** | 旧出站契约,被 `integration/heicode-am-contract.md` 取代 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 给后续开发者的一句话
|
||||
|
||||
要找「蜂群在 HM 侧怎么对接」——**当前答案是「HM 不实现 swarm runtime;但契约已冻结(`runtime-contract v1`/`event-schema v1`),HM 已据冻结版落地只读查询 + stop + 事件 SSE(#45/#46/PR #59),代码在 `controller/agent_swarm_query.go`;唯一剩余计费缺口是 per-user `sk-` 注入(#60),阻塞于 `agent_swarm#16`」**;旧设计(HM 主导编排 / 仅 `/tasks` / `HeiCode-Swarm` 仓名)已废,别从 git 历史里捞旧文档当依据,按本文与 `agent_swarm` 仓的最新结论走。
|
||||
@@ -1,53 +0,0 @@
|
||||
# 客户端错误遥测 — 生产开启前置 Checklist(定稿)
|
||||
|
||||
> 工单:#44(拆分自 #32 EPIC)· 状态:**工程项已就绪;开启前置 = 隐私披露文档已发布 + 评审通过**
|
||||
> 适用:把 `HEICODE_TELEMETRY_ENABLED` 从默认 `false` 翻到 `true` 之前的强制检查清单。
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
遥测**默认关闭**(摄入端点回 410 kill switch)。在本清单**全部勾选**前,生产 **不得** 开启;尤其是 §3 的隐私披露是硬前置——工程已把代码侧防线做齐,但「设备 ID 可关联账号」属隐私敏感,必须先在隐私文档如实披露并发布。
|
||||
|
||||
## 1. 代码侧控制(已实现,可现场核验)
|
||||
|
||||
| 控制 | 实现 | 核验方式 |
|
||||
|---|---|---|
|
||||
| 默认关闭 + kill switch | `HEICODE_TELEMETRY_ENABLED` 默认 `false`,关时摄入端点返回 **410** | `GET /api/heicode/config` → `telemetry.enabled=false`;`POST /api/heicode/telemetry/events` → 410 |
|
||||
| 不计费、不进 consume log | 独立 `telemetry_events` 表,摄入不写消费日志/不动 quota | 代码 `controller/heicode_telemetry.go`;开启后抽查无 consume log |
|
||||
| 仅设备配对可上送 | 需 V2 设备签名 + `X-Heicode-Device-Id`;会话-only 拒绝(403) | 无设备头请求 → 403 |
|
||||
| 批量/尺寸限制 | 顶层数组 1–20 条、≤256KB | 超限 → 413 |
|
||||
| context 字段白名单(#42) | 仅 `route/retryable/phase/exit_code/duration_ms/attempt`,其余键丢弃 | `TestFilterTelemetryContext_Whitelist` |
|
||||
| 单字段尺寸上限 | `stack_top`/`context` 脱敏后截断 8KiB | `TestCapTelemetryField` |
|
||||
| 服务端二次脱敏 | `stack_top`/`context` 剥离 sk-/Bearer/URL token/JSON 密钥字段 | `TestTelemetryToModel_RedactsSecrets` |
|
||||
| 数据保留期(#43) | `HEICODE_TELEMETRY_RETENTION_DAYS`(默认 30)+ master-only 每日清理 | `GET /api/heicode/config` → `retention_days`;`TestDeleteTelemetryEventsBefore` |
|
||||
|
||||
**禁止采集(白名单已强制)**:prompt、代码正文、token、邮箱、完整文件路径、用户名路径、IP 原文等可识别信息——非白名单 `context` 键一律丢弃 + 服务端脱敏兜底。
|
||||
|
||||
## 2. 生产配置确认(开启时设置 + 复核)
|
||||
|
||||
- [ ] `HEICODE_TELEMETRY_ENABLED=true`(仅在 §3 全部完成后)
|
||||
- [ ] `HEICODE_TELEMETRY_RETENTION_DAYS` 已确认(建议 ≤90;默认 30)
|
||||
- [ ] `HEICODE_TELEMETRY_RETENTION_INTERVAL_HOURS` 已确认(默认 24)
|
||||
- [ ] 仅 master 节点跑清理任务(`IsMasterNode`,已在 `main.go` 内置)
|
||||
- [ ] `GET /api/heicode/config` 返回的 `telemetry` 块与上述配置一致
|
||||
|
||||
## 3. 隐私披露前置(硬性,开启的真正闸门)
|
||||
|
||||
> 这一节是**文档 + 评审**条件,不是代码项;由对应文档 PR 经评审通过即满足。
|
||||
|
||||
- [ ] **heicodeDocs 隐私文档如实披露**「客户端错误遥测;**设备 ID 可关联账号、非匿名**;采集范围(崩溃类别/错误码哈希/脱敏调用栈/运行环境);保留期;不采集 prompt/代码/token/邮箱/完整路径/IP 原文」(跟踪:#34,草案已在 #34 给出)
|
||||
- [ ] 该隐私披露 PR **已评审通过并合并**(在 #44 / #34 留 PR 链接为开启决定留痕)
|
||||
- [ ] 隐私文档**已发布上线**(用户可见),且与实际采集行为一致(`法律声明.md`「隐私披露必须与真实采集行为一致」)
|
||||
|
||||
## 4. 上线 / 回滚验证(开启当次执行)
|
||||
|
||||
- [ ] 开启前:`POST /api/heicode/telemetry/events` 返回 **410**(确认 kill switch 基线)
|
||||
- [ ] 开启后:客户端真实上送一批 → `200 {accepted:n}`,落 `telemetry_events` 表
|
||||
- [ ] 抽查入库行:`stack_top`/`context` 已脱敏;`context` 仅白名单键;无 prompt/邮箱/路径/IP
|
||||
- [ ] 确认**无** consume log / quota 变化(遥测不计费)
|
||||
- [ ] **回滚演练**:把 `HEICODE_TELEMETRY_ENABLED` 改回 `false` → 端点回 410、客户端停送(kill switch 可用)
|
||||
- [ ] 保留期清理任务在日志中可见(`secret/telemetry retention task started`)
|
||||
|
||||
## 5. 结论门
|
||||
|
||||
**只有 §1 已核验 + §2 已确认 + §3 隐私披露已发布 + §4 验证通过,才允许在生产保持 `HEICODE_TELEMETRY_ENABLED=true`。**
|
||||
任一项不满足 → 维持默认关闭(410)。本清单作为 #44 的「checklist 定稿」交付;§3 的隐私披露文档(#34)是开启的前置条件。
|
||||
@@ -0,0 +1,441 @@
|
||||
# Agent Manager 蜂群模式对接任务清单
|
||||
|
||||
更新时间:2026-05-28
|
||||
发给:Agent Manager / HeiCode-Swarm 负责人
|
||||
来源:Heicode Manager 当前代码、生产 1.4.9 验证结果、蜂群资料包和 Heicode 产品资料包。
|
||||
|
||||
## 1. 结论
|
||||
|
||||
Heicode Manager 端已经具备蜂群模式所需的控制面能力:创建 deployment、桥接 Runtime、保存 `runtime_deployment_id` / `swarm_id`、接收 callback、落 artifact、落 approval、聚合 timeline、审批结果回传 Runtime、页面展示 task flow。
|
||||
|
||||
Agent Manager / Swarm Runtime 当前还需要补齐或确认的是:真实创建 Swarm Run、生成任务图、Agent claim/heartbeat/handoff/retry/blocked 状态机、artifact 真实产出、审批暂停和恢复、日志指标、用量回传。
|
||||
|
||||
## 2. 当前 Manager 已完成
|
||||
|
||||
| 能力 | 状态 | 说明 |
|
||||
|---|---|---|
|
||||
| Runtime 创建桥接 | 已完成 | Manager 可通过 `AGENT_RUNTIME_BASE_URL` + `AGENT_RUNTIME_CREATE_PATH` 调 Agent Manager |
|
||||
| Runtime 停止桥接 | 已完成 | 默认 `POST /api/agent/deployments/{deployment_id}/stop` |
|
||||
| `/api/swarms` adapter | 已完成 | Manager 用户态入口已存在,后续可切真实 Runtime |
|
||||
| callback 接收 | 已完成 | `POST /api/agent/callbacks/swarm-events` |
|
||||
| callback 鉴权 | 已完成 | 支持 service token 或 HMAC |
|
||||
| callback 幂等 | 已完成 | 按 `event_id` / `idempotency_key` 去重 |
|
||||
| task flow 校验 | 已完成 | `task.*` / `handoff.*` 缺关键字段会返回 `CALLBACK_SCHEMA_INVALID` |
|
||||
| artifact 落库 | 已完成 | `artifact.created` 可落库并在 deployment 详情展示 |
|
||||
| approval 落库 | 已完成 | `approval.requested` 可转 Manager 审批记录 |
|
||||
| approval decision 回传 | 已完成 | 用户 approve/reject 后 Manager 可回传 Runtime |
|
||||
| 页面验证 | 已完成 | 生产 1.4.9 已验证 `Sub task flow` 展示 task/blocked/retry/handoff |
|
||||
|
||||
## 3. Agent Manager 需要实现或确认的 P0
|
||||
|
||||
| 任务 | 必需 | 原因 | 验收 |
|
||||
|---|---:|---|---|
|
||||
| 提供真实创建 Swarm Run 接口 | 是 | Manager 只能发起,不能替 Runtime 生成真实任务图 | `POST /api/swarms` 或配置等价接口返回真实 `deployment_id` / `swarm_id` |
|
||||
| 返回稳定 Runtime 标识 | 是 | Manager 要保存 `deployment_id <-> runtime_deployment_id/swarm_id` 映射 | 创建响应包含 `deployment_id` 或 `id`,建议同时包含 `swarm_id` |
|
||||
| 生成任务图 | 是 | 蜂群模式核心是 task graph,不是单个占位 deployment | Runtime 内部能创建任务节点,并回调 `task.created` |
|
||||
| Agent claim / heartbeat | 是 | Manager 页面和客户端需要知道哪个 Agent 在做哪个任务 | 回调 `task.claimed`、`task.heartbeat` |
|
||||
| blocked / retry / failed 状态机 | 是 | 失败恢复和阻塞是蜂群运行核心 | 回调 `task.blocked`、`task.retried`、`task.failed`,字段符合 schema |
|
||||
| handoff 状态机 | 是 | 蜂群 Agent 间交接需要可审计 | 回调 `handoff.requested`、`handoff.completed` |
|
||||
| artifact 真实产出 | 是 | Manager 只保存摘要和引用,真实产物来自 Runtime | 回调 `artifact.created`,带 `uri`、`summary`、`artifact_type` |
|
||||
| 高危审批暂停/恢复 | 是 | 高危操作需要客户端审批后 Runtime 才能继续 | Runtime 回调 `approval.requested`,并接收 Manager decision |
|
||||
| 日志/指标 | 是 | 线上排障和验收需要真实运行数据 | 提供 logs/metrics 查询或通过 callback 摘要回传 |
|
||||
| 不记录明文长期密钥 | 是 | 安全红线 | Runtime 只消费 `secret_ref` / `credential_ref`,日志和 artifact metadata 不出现明文密钥 |
|
||||
|
||||
## 4. Runtime 创建接口
|
||||
|
||||
生产目标:
|
||||
|
||||
```http
|
||||
POST /api/swarms
|
||||
Authorization: Bearer <service_token>
|
||||
X-User-ID: <manager_user_id>
|
||||
X-Binding-Scope: <binding_scope>
|
||||
X-Correlation-ID: <correlation_id>
|
||||
X-Idempotency-Key: manager-<manager_deployment_id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
Manager 当前可通过环境变量切换:
|
||||
|
||||
```text
|
||||
AGENT_RUNTIME_BASE_URL=http://20.212.121.126
|
||||
AGENT_RUNTIME_CREATE_PATH=/api/swarms
|
||||
AGENT_RUNTIME_HEALTH_PATH=/api/agent/health
|
||||
AGENT_RUNTIME_STOP_PATH=/api/agent/deployments/{deployment_id}/stop
|
||||
AGENT_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
|
||||
```
|
||||
|
||||
请求体核心形状:
|
||||
|
||||
```json
|
||||
{
|
||||
"orchestration_plan": {
|
||||
"intent_id": "task_xxx",
|
||||
"objective": "完成本轮用户目标",
|
||||
"sub_mode": "agile",
|
||||
"risk_level": "medium",
|
||||
"budget": {
|
||||
"max_tokens": 120000,
|
||||
"max_cost_usd": 8,
|
||||
"max_duration_sec": 3600
|
||||
},
|
||||
"user_context": {
|
||||
"user_id": "22",
|
||||
"channel_id": "heicode"
|
||||
},
|
||||
"billing_context": {
|
||||
"provider": "newapi",
|
||||
"default_model_id": "model_xxx",
|
||||
"allowed_model_ids": ["model_xxx"],
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/model-gateway-key"
|
||||
},
|
||||
"agile_context": {
|
||||
"iteration": "2026-05-28",
|
||||
"stage": "development",
|
||||
"checkpoint": "draft_created",
|
||||
"next_action": "continue",
|
||||
"requires_user_approval": false
|
||||
},
|
||||
"agents": []
|
||||
},
|
||||
"agents": [
|
||||
{
|
||||
"role": "backend",
|
||||
"resource_grants": []
|
||||
}
|
||||
],
|
||||
"resource_grants": [
|
||||
{
|
||||
"grant_id": "grant-task-backend",
|
||||
"resource_id": "repo-main",
|
||||
"resource_type": "git",
|
||||
"permission_scope": ["repo:read", "repo:write:feature-branches"],
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
|
||||
"ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
|
||||
"target_role": "backend"
|
||||
}
|
||||
],
|
||||
"callback": {
|
||||
"url": "https://code.xinghanlab.com/api/agent/callbacks/swarm-events",
|
||||
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agent-callback-signing-key",
|
||||
"subscribed_events": [
|
||||
"deployment.status_changed",
|
||||
"task.created",
|
||||
"task.claimed",
|
||||
"task.heartbeat",
|
||||
"task.blocked",
|
||||
"task.retried",
|
||||
"task.failed",
|
||||
"task.completed",
|
||||
"handoff.requested",
|
||||
"handoff.completed",
|
||||
"approval.requested",
|
||||
"artifact.created",
|
||||
"timeline.updated"
|
||||
]
|
||||
},
|
||||
"sub_mode": "agile",
|
||||
"metadata": {
|
||||
"manager_deployment_id": "dep_xxx",
|
||||
"heicode_deployment_id": "dep_xxx",
|
||||
"heicode_runtime_bridge": true,
|
||||
"correlation_id": "corr_xxx"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
响应必须至少满足一种:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deployment_id": "runtime-dep-123",
|
||||
"swarm_id": "swarm-123",
|
||||
"status": "running"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
或:
|
||||
|
||||
```json
|
||||
{
|
||||
"deployment_id": "runtime-dep-123",
|
||||
"swarm_id": "swarm-123",
|
||||
"status": "running"
|
||||
}
|
||||
```
|
||||
|
||||
Manager 解析规则:
|
||||
|
||||
| Runtime 字段 | Manager 保存到 |
|
||||
|---|---|
|
||||
| `data.deployment_id` / `deployment_id` / `id` | `runtime_deployment_id` |
|
||||
| `data.swarm_id` / `swarm_id` / `runtime_swarm_id` | `runtime_swarm_id` |
|
||||
| `data.status` / `status` | `runtime_state` |
|
||||
|
||||
## 5. Runtime 回调 Manager
|
||||
|
||||
统一入口:
|
||||
|
||||
```http
|
||||
POST https://code.xinghanlab.com/api/agent/callbacks/swarm-events
|
||||
X-Agent-Service-Token: <callback_token>
|
||||
X-Agent-Event-Id: <event_id>
|
||||
X-Correlation-ID: <correlation_id>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
也支持 HMAC:
|
||||
|
||||
```http
|
||||
X-Agent-Timestamp: <unix_ms>
|
||||
X-Agent-Signature: sha256=<hex>
|
||||
```
|
||||
|
||||
签名内容:
|
||||
|
||||
```text
|
||||
timestamp + "." + event_id + "." + raw_body
|
||||
```
|
||||
|
||||
Agent Manager / 蜂群侧可以先拉取 Manager 当前接受的事件 schema:
|
||||
|
||||
```http
|
||||
GET https://code.xinghanlab.com/api/agent/callbacks/swarm-events/schema
|
||||
```
|
||||
|
||||
该接口不返回任何 token 或密钥,只返回事件类型、分类和必填字段,用于联调前核对 `task.*`、`handoff.*`、`artifact.created`、`approval.requested` 等 callback payload 是否符合 Manager 接收规则。
|
||||
|
||||
统一 envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_xxx",
|
||||
"idempotency_key": "evt_xxx",
|
||||
"event_type": "task.blocked",
|
||||
"deployment_id": "dep_xxx",
|
||||
"swarm_id": "swarm-123",
|
||||
"agent_instance_id": "agent-backend-1",
|
||||
"task_id": "task-backend-1",
|
||||
"occurred_at": "2026-05-28T10:00:00Z",
|
||||
"correlation_id": "corr_xxx",
|
||||
"source": "heicode-swarm-runtime",
|
||||
"payload": {}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. task / handoff 事件 schema
|
||||
|
||||
Manager 已上线校验。以下字段缺失会拒绝,返回 `CALLBACK_SCHEMA_INVALID`。
|
||||
|
||||
| event_type | 必填字段 |
|
||||
|---|---|
|
||||
| `task.created` | `task_id`, `payload.title` |
|
||||
| `task.claimed` | `task_id`, `payload.agent_role` |
|
||||
| `task.running` | `task_id`, `payload.agent_role` |
|
||||
| `task.heartbeat` | `task_id`, `payload.agent_role` |
|
||||
| `task.blocked` | `task_id`, `payload.reason` |
|
||||
| `task.retried` | `task_id`, `payload.attempt` |
|
||||
| `task.released` | `task_id`, `payload.agent_role` |
|
||||
| `task.failed` | `task_id`, `payload.reason` |
|
||||
| `task.completed` | `task_id` |
|
||||
| `handoff.requested` | `task_id`, `payload.from_role`, `payload.to_role` |
|
||||
| `handoff.completed` | `task_id`, `payload.from_role`, `payload.to_role` |
|
||||
| `artifact.created` | `artifact.artifact_id` 或 `payload.artifact_id` |
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_task_blocked_1",
|
||||
"event_type": "task.blocked",
|
||||
"deployment_id": "dep_xxx",
|
||||
"swarm_id": "swarm-123",
|
||||
"agent_instance_id": "agent-backend-1",
|
||||
"task_id": "task-backend-1",
|
||||
"source": "heicode-swarm-runtime",
|
||||
"payload": {
|
||||
"task_id": "task-backend-1",
|
||||
"agent_role": "backend",
|
||||
"reason": "等待前端接口字段确认",
|
||||
"severity": "warning",
|
||||
"next_action": "handoff_to_frontend"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_handoff_1",
|
||||
"event_type": "handoff.requested",
|
||||
"deployment_id": "dep_xxx",
|
||||
"swarm_id": "swarm-123",
|
||||
"task_id": "task-backend-1",
|
||||
"source": "heicode-swarm-runtime",
|
||||
"payload": {
|
||||
"task_id": "task-backend-1",
|
||||
"from_role": "backend",
|
||||
"to_role": "frontend",
|
||||
"summary": "后端接口已完成,交给前端接页面"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 7. artifact 回调
|
||||
|
||||
Runtime 不要把大文件正文塞进 callback。只回写摘要和可访问引用。
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_artifact_1",
|
||||
"event_type": "artifact.created",
|
||||
"deployment_id": "dep_xxx",
|
||||
"swarm_id": "swarm-123",
|
||||
"task_id": "task-backend-1",
|
||||
"source": "heicode-swarm-runtime",
|
||||
"artifact": {
|
||||
"artifact_id": "art_backend_patch_1",
|
||||
"artifact_type": "code_patch",
|
||||
"title": "Backend patch",
|
||||
"summary": "完成工单列表和状态流转接口",
|
||||
"uri": "git://repo#feature/task-backend-1",
|
||||
"checksum": "sha256:abc123",
|
||||
"metadata": {
|
||||
"redacted": true,
|
||||
"agent_role": "backend"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 8. approval 回调和 decision 接口
|
||||
|
||||
Runtime 在高危动作前必须暂停,并回调 Manager:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "evt_approval_1",
|
||||
"event_type": "approval.requested",
|
||||
"deployment_id": "dep_xxx",
|
||||
"swarm_id": "swarm-123",
|
||||
"agent_instance_id": "agent-backend-1",
|
||||
"task_id": "task-backend-1",
|
||||
"source": "heicode-swarm-runtime",
|
||||
"payload": {
|
||||
"approval_id": "appr_runtime_1",
|
||||
"operation": "git.write",
|
||||
"resource_id": "repo-main",
|
||||
"resource_type": "git",
|
||||
"resource_scope": "feature/*",
|
||||
"target_role": "backend",
|
||||
"risk_level": "high",
|
||||
"requires_credential": true,
|
||||
"secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/repo-main",
|
||||
"ttl_seconds": 900,
|
||||
"reason": "需要写入功能分支"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Manager 用户同意或拒绝后会调用 Runtime:
|
||||
|
||||
```http
|
||||
POST /api/swarms/{swarm_id}/approvals/{approval_id}
|
||||
Authorization: Bearer <service_token>
|
||||
X-Correlation-ID: <correlation_id>
|
||||
X-Idempotency-Key: approval-decision-<approval_id>-<approved|rejected>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"approval_id": "appr_runtime_1",
|
||||
"decision": "approved",
|
||||
"manager_deployment_id": "dep_xxx",
|
||||
"runtime_deployment_id": "runtime-dep-123",
|
||||
"swarm_id": "swarm-123",
|
||||
"operation": "git.write",
|
||||
"resource_id": "repo-main",
|
||||
"resource_type": "git",
|
||||
"resource_scope": "feature/*",
|
||||
"target_role": "backend",
|
||||
"risk_level": "high",
|
||||
"requires_credential": true,
|
||||
"user_id": "22",
|
||||
"credential_ref": "lease://agent/lease_xxx",
|
||||
"lease_id": "lease_xxx",
|
||||
"lease_expires_at": 1779850900000
|
||||
}
|
||||
```
|
||||
|
||||
注意:Manager 不会把长期 `secret_ref` 发回 decision,只会在同意时发短期 `credential_ref`。
|
||||
|
||||
## 9. 停止接口
|
||||
|
||||
默认:
|
||||
|
||||
```http
|
||||
POST /api/agent/deployments/{runtime_deployment_id}/stop
|
||||
Authorization: Bearer <service_token>
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"reason": "Heicode Manager requested stop",
|
||||
"manager_deployment_id": "dep_xxx"
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"deployment_id": "runtime-dep-123",
|
||||
"swarm_id": "swarm-123",
|
||||
"status": "stopped"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 10. 验收用例
|
||||
|
||||
| 用例 | 操作 | 期望 |
|
||||
|---|---|---|
|
||||
| health | `GET /api/agent/health` | 200,`status=healthy` |
|
||||
| create | Manager 创建 deployment | Runtime 返回 `deployment_id` 和 `swarm_id` |
|
||||
| task flow | Runtime 回调 `task.created/claimed/heartbeat/blocked/retried/completed` | Manager timeline 和页面 Sub task flow 可见 |
|
||||
| handoff | Runtime 回调 `handoff.requested/completed` | Manager 页面可见 from/to role |
|
||||
| schema 拒绝 | 发送缺 `to_role` 的 `handoff.requested` | Manager 返回 `CALLBACK_SCHEMA_INVALID` |
|
||||
| artifact | Runtime 回调 `artifact.created` | Manager artifacts 列表出现产物 |
|
||||
| approval | Runtime 回调 `approval.requested` | Manager pending approval 出现 |
|
||||
| decision | 用户 approve/reject | Runtime decision 接口收到 `approved/rejected` |
|
||||
| stop | Manager stop deployment | Runtime 状态变为 stopped |
|
||||
| 安全 | 搜索日志和回调 payload | 不出现 password/token/private_key/access_key/connection_string 明文 |
|
||||
|
||||
## 11. Agent Manager 不需要做的事
|
||||
|
||||
| 不需要做 | 原因 |
|
||||
|---|---|
|
||||
| 实现桌面客户端 V2 body 加密 | 这是客户端到 Manager 的协议,Manager 已支持 |
|
||||
| 直接让普通用户访问 Azure Key Vault | 密钥入口由 Manager 控制,Runtime 只消费 `secret_ref` 或短期 `credential_ref` |
|
||||
| 在 callback 中发送明文模型 key / Git token / 云密钥 | 安全红线,Manager 会拒绝疑似明文敏感字段 |
|
||||
| 替 Manager 保存用户、资源绑定、审批主记录 | Manager 是控制面和记录面 |
|
||||
|
||||
## 12. 联调顺序建议
|
||||
|
||||
1. Agent Manager 确认 health、create、stop、approval decision 路径。
|
||||
2. Manager 配置 `AGENT_RUNTIME_CREATE_PATH=/api/swarms` 后创建一个真实 deployment。
|
||||
3. Agent Manager 回调 `task.created`、`task.claimed`、`task.heartbeat`。
|
||||
4. 回调 `task.blocked`、`handoff.requested`、`handoff.completed`、`task.retried`。
|
||||
5. 回调 `artifact.created`。
|
||||
6. 回调 `approval.requested`,客户端/Manager approve 后 Runtime 接收 decision 并继续。
|
||||
7. Manager stop,Runtime 停止任务并回调最终状态。
|
||||
@@ -129,18 +129,19 @@ Backend Agent
|
||||
|
||||
### 7. Agent 执行闭环
|
||||
|
||||
部署后客户端拿到 agent 的公网地址,**直连 agent 持续对话推进开发**(HM 不在对话回路;agent 用模型时走 HM `/v1`)。
|
||||
Heicode 不是只把任务丢给 Agent 一次就结束,而是会在开发过程中持续调用 Agent 完成子环节。
|
||||
|
||||
闭环应表达为:
|
||||
|
||||
```text
|
||||
客户端直连 agent,输入目标或追加需求
|
||||
-> agent 自行推进需求/设计/开发/测试/修复
|
||||
-> agent 按需要调用已授权的 SK 工具
|
||||
-> agent 用模型时走 HM /v1(计费到用户)
|
||||
-> 中间结果与产物回到客户端
|
||||
-> 用户继续追问、修正或审批高危动作
|
||||
-> agent 继续推进,直至交付整理与部署
|
||||
客户端输入目标或追加需求
|
||||
-> Heicode 生成下一步任务
|
||||
-> Agent 执行需求/设计/开发/测试/修复中的当前子环节
|
||||
-> Agent 按需要调用已授权的 SK 工具
|
||||
-> Heicode 回传中间结果给客户端
|
||||
-> 用户继续追问、修正或审批
|
||||
-> Agent 继续下一子环节
|
||||
-> 最终由 Agent 完成交付整理与部署
|
||||
```
|
||||
|
||||
这意味着用户看到的不是一次性“已部署 Agent”,而是一个可连续推进的开发循环。
|
||||
|
||||
@@ -246,13 +246,13 @@ Ops Agent 请求部署到生产环境。
|
||||
这里的真实闭环是:
|
||||
|
||||
```text
|
||||
我在客户端直连 agent、补充要求
|
||||
-> agent 自行推进需求、开发、测试、修复或部署中的当前任务
|
||||
-> agent 按权限调用已绑定的 SK 工具
|
||||
-> agent 用模型时走 HM /v1
|
||||
我在客户端补充要求
|
||||
-> Heicode 判断下一步要推进哪个子环节
|
||||
-> Agent 执行需求、开发、测试、修复或部署中的当前任务
|
||||
-> Agent 按权限调用已绑定的 SK 工具
|
||||
-> 中间结果回到客户端
|
||||
-> 我继续修正方向或批准高危动作
|
||||
-> agent 继续推进直到交付和部署完成
|
||||
-> Agent 继续推进直到交付和部署完成
|
||||
```
|
||||
|
||||
### 18. 查看执行状态
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/heicode/manager/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// startTemplateAgentAsync must NOT block the caller on the (slow) AM start, and
|
||||
// must flip the pre-persisted Pending record to running once AM responds — this
|
||||
// is the core of the 504 fix (deploy returns immediately; status lands later).
|
||||
func TestStartTemplateAgentAsync_PendingToRunning(t *testing.T) {
|
||||
setupResourceControllerTestDB(t)
|
||||
require.NoError(t, model.DB.AutoMigrate(&model.AgentDeployment{}))
|
||||
|
||||
// Slow AM mock — simulates the ~30s start the handler must not block on.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"data":{"namespace":"rt-async-1","access_info":{"domain":"https://async.agents.example"},"status":"running"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL)
|
||||
|
||||
// What HeicodeDeployAgent persists before returning.
|
||||
require.NoError(t, model.DB.Create(&model.AgentDeployment{
|
||||
DeploymentID: "dep_async_run",
|
||||
UserID: "22",
|
||||
TemplateID: "architect",
|
||||
Status: "Pending",
|
||||
}).Error)
|
||||
|
||||
start := time.Now()
|
||||
startTemplateAgentAsync("dep_async_run", 0, amStartArgs{ManagerDeploymentID: "dep_async_run", UserID: "22", TemplateKey: "architect"})
|
||||
require.Less(t, time.Since(start), 50*time.Millisecond, "must not block on the AM start call")
|
||||
|
||||
var got model.AgentDeployment
|
||||
require.Eventually(t, func() bool {
|
||||
model.DB.Where("deployment_id = ?", "dep_async_run").First(&got)
|
||||
return got.Status == "running"
|
||||
}, 3*time.Second, 20*time.Millisecond, "Pending should flip to running after async AM start")
|
||||
require.Equal(t, "rt-async-1", got.RuntimeDeploymentID)
|
||||
require.Equal(t, "https://async.agents.example", got.Subdomain)
|
||||
}
|
||||
|
||||
// On AM failure the record must be marked failed (with a reason), not left
|
||||
// stuck on Pending — so the client surfaces a failure instead of a hang.
|
||||
func TestStartTemplateAgentAsync_FailureMarksFailed(t *testing.T) {
|
||||
setupResourceControllerTestDB(t)
|
||||
require.NoError(t, model.DB.AutoMigrate(&model.AgentDeployment{}))
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, _ = w.Write([]byte(`{"success":false,"message":"AM boom"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL)
|
||||
|
||||
require.NoError(t, model.DB.Create(&model.AgentDeployment{
|
||||
DeploymentID: "dep_async_fail",
|
||||
UserID: "22",
|
||||
TemplateID: "architect",
|
||||
Status: "Pending",
|
||||
}).Error)
|
||||
|
||||
startTemplateAgentAsync("dep_async_fail", 0, amStartArgs{ManagerDeploymentID: "dep_async_fail", UserID: "22", TemplateKey: "architect"})
|
||||
|
||||
var got model.AgentDeployment
|
||||
require.Eventually(t, func() bool {
|
||||
model.DB.Where("deployment_id = ?", "dep_async_fail").First(&got)
|
||||
return got.Status == "failed"
|
||||
}, 3*time.Second, 20*time.Millisecond, "AM failure should mark the record failed")
|
||||
require.NotEmpty(t, got.FailureReason)
|
||||
}
|
||||
|
||||
// Concurrency boundary: if the user stops the agent while AM is still starting,
|
||||
// the async goroutine must NOT resurrect it to running, and must delete the
|
||||
// runtime AM just started (orphan cleanup). The AM start is held until the test
|
||||
// has marked the record stopped, so the conditional update always runs after.
|
||||
func TestStartTemplateAgentAsync_StoppedDuringStart_NoResurrect(t *testing.T) {
|
||||
setupResourceControllerTestDB(t)
|
||||
require.NoError(t, model.DB.AutoMigrate(&model.AgentDeployment{}))
|
||||
|
||||
release := make(chan struct{})
|
||||
var mu sync.Mutex
|
||||
var deletedRuntime string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/agents":
|
||||
<-release // block the start until the test marks the row stopped
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"data":{"namespace":"rt-race-1","access_info":{"domain":"https://race.agents.example"},"status":"running"}}`))
|
||||
case r.Method == http.MethodDelete && strings.HasPrefix(r.URL.Path, "/agents/"):
|
||||
mu.Lock()
|
||||
deletedRuntime = strings.TrimPrefix(r.URL.Path, "/agents/")
|
||||
mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true}`))
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
t.Setenv("AGENT_RUNTIME_BASE_URL", srv.URL)
|
||||
|
||||
require.NoError(t, model.DB.Create(&model.AgentDeployment{
|
||||
DeploymentID: "dep_race_stop",
|
||||
UserID: "22",
|
||||
TemplateID: "architect",
|
||||
Status: "Pending",
|
||||
}).Error)
|
||||
|
||||
startTemplateAgentAsync("dep_race_stop", 0, amStartArgs{ManagerDeploymentID: "dep_race_stop", UserID: "22", TemplateKey: "architect"})
|
||||
|
||||
// User stops the agent while AM is still starting. Done before releasing AM, so
|
||||
// the goroutine's Pending-guarded update is guaranteed to see "stopped".
|
||||
require.NoError(t, model.DB.Model(&model.AgentDeployment{}).
|
||||
Where("deployment_id = ?", "dep_race_stop").
|
||||
Updates(map[string]any{"status": "stopped"}).Error)
|
||||
close(release)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return deletedRuntime == "rt-race-1"
|
||||
}, 3*time.Second, 20*time.Millisecond, "orphan runtime must be deleted in AM")
|
||||
|
||||
var row model.AgentDeployment
|
||||
require.NoError(t, model.DB.Where("deployment_id = ?", "dep_race_stop").First(&row).Error)
|
||||
require.Equal(t, "stopped", row.Status, "stopped record must not be flipped back to running")
|
||||
require.Empty(t, row.RuntimeDeploymentID, "runtime id must not be backfilled onto a stopped record")
|
||||
}
|
||||
@@ -29,7 +29,6 @@ type agentCallbackEnvelope struct {
|
||||
TaskID string `json:"task_id"`
|
||||
OccurredAt string `json:"occurred_at"`
|
||||
CorrelationID string `json:"correlation_id"`
|
||||
Sequence int `json:"sequence"`
|
||||
Source string `json:"source"`
|
||||
Metadata map[string]any `json:"metadata"`
|
||||
Payload map[string]any `json:"payload"`
|
||||
@@ -479,14 +478,6 @@ var agentCallbackEventRequiredFields = map[string][]string{
|
||||
"handoff.requested": {"task_id", "from_role", "to_role"},
|
||||
"handoff.completed": {"task_id", "from_role", "to_role"},
|
||||
"approval.requested": {"approval_id", "operation", "risk_level"},
|
||||
// agent_swarm event-schema FROZEN v1 新增 6 类(#15)。必填字段先按最小集登记
|
||||
// (避免误拒);agent_swarm PR #28 合并后按其 §4 表精校。
|
||||
"handoff.created": {"task_id"},
|
||||
"approval.approved": {"approval_id"},
|
||||
"approval.rejected": {"approval_id"},
|
||||
"swarm.completed": {},
|
||||
"swarm.failed": {"reason"},
|
||||
"swarm.stopped": {},
|
||||
"artifact.created": {"artifact_id"},
|
||||
"timeline.updated": {"title"},
|
||||
"sk_tool.called": {"tool_name", "tool_invocation_id"},
|
||||
@@ -512,13 +503,7 @@ var agentCallbackEventCategories = map[string]string{
|
||||
"task.completed": "swarm_task_flow",
|
||||
"handoff.requested": "swarm_task_flow",
|
||||
"handoff.completed": "swarm_task_flow",
|
||||
"handoff.created": "swarm_task_flow",
|
||||
"approval.requested": "approval",
|
||||
"approval.approved": "approval",
|
||||
"approval.rejected": "approval",
|
||||
"swarm.completed": "swarm_lifecycle",
|
||||
"swarm.failed": "swarm_lifecycle",
|
||||
"swarm.stopped": "swarm_lifecycle",
|
||||
"artifact.created": "artifact",
|
||||
"timeline.updated": "timeline",
|
||||
"sk_tool.called": "sk",
|
||||
@@ -724,7 +709,6 @@ func AgentReceiveRuntimeEventCallback(c *gin.Context) {
|
||||
UserID: record.Plan.UserContext.UserID,
|
||||
BindingScope: firstPlanBindingScope(record.Plan),
|
||||
CorrelationID: strings.TrimSpace(payload.CorrelationID),
|
||||
Sequence: payload.Sequence,
|
||||
Source: strings.TrimSpace(payload.Source),
|
||||
Result: "ok",
|
||||
PayloadJSON: string(payloadJSON),
|
||||
|
||||
@@ -776,16 +776,6 @@ func validateOrchestrationPlan(c *gin.Context, plan agentOrchestrationPlan) bool
|
||||
agentError(c, "POLICY_REJECTED", "at least one agent is required")
|
||||
return false
|
||||
}
|
||||
// Upper bound on agents per orchestration plan (defense-in-depth on this
|
||||
// admin-only control-plane path): a single call must not request an unbounded
|
||||
// number of agents (resource-exhaustion / cost blow-up). Mirrors the team-tier
|
||||
// ceiling (8); env-overridable, <=0 disables. The per-user deploy-COUNT cap lives
|
||||
// on the user self-service path (HeicodeDeployAgent); this is the per-plan SIZE
|
||||
// guard. (#8)
|
||||
if maxPerPlan := common.GetEnvOrDefault("HEICODE_MAX_AGENTS_PER_PLAN", 8); maxPerPlan > 0 && len(plan.Agents) > maxPerPlan {
|
||||
agentError(c, "POLICY_REJECTED", "orchestration plan exceeds the maximum of "+strconv.Itoa(maxPerPlan)+" agents")
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(plan.UserContext.UserID) == "" {
|
||||
agentError(c, "POLICY_REJECTED", "user_context.user_id is required")
|
||||
return false
|
||||
|
||||
@@ -595,28 +595,6 @@ func TestAgentCreateDeploymentRejectsInvalidSubMode(t *testing.T) {
|
||||
require.Empty(t, agentDeployments)
|
||||
}
|
||||
|
||||
func TestAgentCreateDeploymentRejectsTooManyAgents(t *testing.T) {
|
||||
setupAgentControlPlaneTestDB(t)
|
||||
resetAgentControlPlaneState(t)
|
||||
|
||||
// 9 agents > default per-plan cap of 8. The size guard short-circuits before
|
||||
// per-agent validation, so the filler agents need no further detail. (#8)
|
||||
plan := baseAgentResourceGrantPlan()
|
||||
for i := 0; i < 8; i++ {
|
||||
plan.Agents = append(plan.Agents, agentAgentPlan{
|
||||
RoleTemplate: "extra-" + strconv.Itoa(i),
|
||||
Goal: "filler",
|
||||
})
|
||||
}
|
||||
require.Len(t, plan.Agents, 9)
|
||||
|
||||
_, envelope := postAgentCreateDeployment(t, plan)
|
||||
require.False(t, envelope.Success)
|
||||
require.Equal(t, "POLICY_REJECTED", envelope.Error.Code)
|
||||
require.Contains(t, envelope.Error.Message, "maximum of 8 agents")
|
||||
require.Empty(t, agentDeployments) // rejected before any deployment is persisted
|
||||
}
|
||||
|
||||
func TestAgentCreateDeploymentRejectsNonAzureSecretRef(t *testing.T) {
|
||||
setupAgentControlPlaneTestDB(t)
|
||||
resetAgentControlPlaneState(t)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package controller
|
||||
|
||||
import "testing"
|
||||
|
||||
// #55: agent endpoint 安全级别推断 —— 明文/裸主机 = none(客户端生产可拒),显式 https = tls。
|
||||
func TestAgentEndpointSecurity(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
scheme, prof string
|
||||
secure bool
|
||||
}{
|
||||
{"https://dep-x.agents.example", "https", "tls", true},
|
||||
{"http://dep-x.agents.example", "http", "none", false},
|
||||
{"dep-x.taijiagnet.com", "http", "none", false}, // 裸主机:AM 当前明文
|
||||
{" HTTPS://Dep.Example ", "https", "tls", true},
|
||||
{"", "http", "none", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
sc, pr, se := agentEndpointSecurity(c.in)
|
||||
if sc != c.scheme || pr != c.prof || se != c.secure {
|
||||
t.Errorf("agentEndpointSecurity(%q) = (%s,%s,%v), want (%s,%s,%v)", c.in, sc, pr, se, c.scheme, c.prof, c.secure)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,455 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// Preflight / execution-summary (#29 EPIC, sub-issues #39 缺失项检测 + #40 可读执行摘要).
|
||||
//
|
||||
// Manager 是辅助控制台,不是编码入口:用户在启动 agent 前应看到一份「执行摘要」——
|
||||
// 这个 agent 会用哪些资源、还缺什么、有哪些高危操作、预算上限是多少 —— 只确认摘要而非
|
||||
// 面对完整参数(产品文档「准备清单 / 推荐摘要」第四、五步)。
|
||||
//
|
||||
// 本文件实现只读的 preflight:GET /api/heicode/preflight?template_id=&binding_ids=1,2,3
|
||||
// 返回缺失项 + 可读摘要。#41(confirm + 审计 + 防篡改版本校验)在此之上单独实现。
|
||||
//
|
||||
// 红线(#40):resource 视图绝不暴露 secret_ref / channelId / base_url / price 等敏感字段;
|
||||
// 高危操作用固定 enum,不自由文本。
|
||||
|
||||
// 高危操作固定 enum(#40 红线):只能取以下值。
|
||||
const (
|
||||
highRiskProductionDeploy = "production_deploy" // 生产部署 / 代码改动推送
|
||||
highRiskDBWrite = "db_write" // 数据库写入
|
||||
highRiskCloudDelete = "cloud_resource_delete" // 云资源删除
|
||||
highRiskProductionSecret = "production_secret" // 生产密钥访问
|
||||
highRiskLargeBudget = "large_budget" // 大额预算消耗
|
||||
)
|
||||
|
||||
var highRiskOpLabels = map[string]string{
|
||||
highRiskProductionDeploy: "生产部署 / 代码改动",
|
||||
highRiskDBWrite: "数据库写入",
|
||||
highRiskCloudDelete: "云资源删除",
|
||||
highRiskProductionSecret: "生产密钥访问",
|
||||
highRiskLargeBudget: "大额预算消耗",
|
||||
}
|
||||
|
||||
// 准备清单要求用户连接的资源类别(#39 缺失项检测)。budget 单独判定。
|
||||
var preflightRequiredKinds = []struct {
|
||||
kind string // 与 ResourceBinding.ResourceType 对齐
|
||||
label string
|
||||
}{
|
||||
{"git", "代码仓库(Git)"},
|
||||
{"sk", "SK 资源包"},
|
||||
{"project_document", "项目文档"},
|
||||
{"cloud_account", "云账号"},
|
||||
}
|
||||
|
||||
type preflightRole struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
Name string `json:"name"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
// preflightResource 是资源的**脱敏**视图:绝不含 secret_ref/channelId/base_url/price。
|
||||
type preflightResource struct {
|
||||
BindingID int `json:"binding_id"`
|
||||
Type string `json:"type"`
|
||||
Provider string `json:"provider"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
HasSecret bool `json:"has_secret"` // 是否已绑定凭证(布尔,不含凭证本身)
|
||||
}
|
||||
|
||||
type preflightMissing struct {
|
||||
Kind string `json:"kind"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type preflightHighRisk struct {
|
||||
Op string `json:"op"` // 固定 enum
|
||||
Label string `json:"label"` // 中文展示
|
||||
RequiresApproval bool `json:"requires_approval"`
|
||||
}
|
||||
|
||||
type preflightBudget struct {
|
||||
RemainingQuota int64 `json:"remaining_quota"`
|
||||
QuotaPerUnit float64 `json:"quota_per_unit"`
|
||||
TierMaxAgents int `json:"tier_max_agents"`
|
||||
CurrentAgents int `json:"current_agents"`
|
||||
}
|
||||
|
||||
type preflightSummary struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
AgentRole preflightRole `json:"agent_role"`
|
||||
Resources []preflightResource `json:"resources"`
|
||||
InvalidBindings []int `json:"invalid_bindings"` // 请求里无效/非本人/非 active 的绑定 id
|
||||
Missing []preflightMissing `json:"missing"`
|
||||
HighRiskOps []preflightHighRisk `json:"high_risk_ops"`
|
||||
Budget preflightBudget `json:"budget"`
|
||||
ApprovalPolicy gin.H `json:"approval_policy"`
|
||||
Ready bool `json:"ready"` // 缺失项为空 + agent 配额未满
|
||||
Version string `json:"version,omitempty"` // 防篡改摘要版本(#41);由稳定子集派生
|
||||
|
||||
// tplDigest 是模板**安全面**(definition + model + name)的摘要,纳入版本哈希,使管理员
|
||||
// 修改同一 template_key 的 definition/model 后旧确认失效(#41 复审 #3)。不序列化。
|
||||
tplDigest string
|
||||
}
|
||||
|
||||
// confirmBindingIDs 返回摘要里有效(已解析)资源的绑定 id,用于审计记录。
|
||||
func (s preflightSummary) confirmBindingIDs() []int {
|
||||
ids := make([]int, 0, len(s.Resources))
|
||||
for _, r := range s.Resources {
|
||||
ids = append(ids, r.BindingID)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// computePreflightVersion 在摘要的**安全相关且稳定**子集上派生版本哈希(#41):
|
||||
// template_id + 资源(binding_id/type/provider/name/status/has_secret)+ 高危操作 +
|
||||
// 必需资源类缺失项。**刻意排除**易变的预算数字(remaining_quota 随每次调用变化)与
|
||||
// budget/agent_slot 缺失项,否则版本会无意义地频繁变化导致部署总被拒。资源(增删/改类型/
|
||||
// 改 secret)或高危面变化 → 哈希变化 → 部署校验拒绝(防篡改 / 防漂移)。
|
||||
func computePreflightVersion(s preflightSummary) string {
|
||||
parts := make([]string, 0, len(s.Resources)+len(s.HighRiskOps)+len(s.Missing)+2)
|
||||
parts = append(parts, "tpl="+s.TemplateID)
|
||||
parts = append(parts, "tpld="+s.tplDigest) // 模板安全面摘要(#41 复审 #3:模板变更翻转版本)
|
||||
|
||||
res := make([]string, 0, len(s.Resources))
|
||||
for _, r := range s.Resources {
|
||||
res = append(res, strconv.Itoa(r.BindingID)+":"+r.Type+":"+r.Provider+":"+r.Name+":"+r.Status+":"+boolStr(r.HasSecret))
|
||||
}
|
||||
sort.Strings(res)
|
||||
parts = append(parts, "res=["+strings.Join(res, ",")+"]")
|
||||
|
||||
ops := make([]string, 0, len(s.HighRiskOps))
|
||||
for _, h := range s.HighRiskOps {
|
||||
ops = append(ops, h.Op)
|
||||
}
|
||||
sort.Strings(ops)
|
||||
parts = append(parts, "risk=["+strings.Join(ops, ",")+"]")
|
||||
|
||||
// 仅纳入「必需资源类」缺失(git/sk/project_document/cloud_account),排除 budget/agent_slot。
|
||||
miss := make([]string, 0)
|
||||
for _, m := range s.Missing {
|
||||
if m.Kind != "budget" && m.Kind != "agent_slot" {
|
||||
miss = append(miss, m.Kind)
|
||||
}
|
||||
}
|
||||
sort.Strings(miss)
|
||||
parts = append(parts, "missing=["+strings.Join(miss, ",")+"]")
|
||||
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
|
||||
return "pfv1_" + hex.EncodeToString(sum[:])[:32]
|
||||
}
|
||||
|
||||
func boolStr(b bool) string {
|
||||
if b {
|
||||
return "1"
|
||||
}
|
||||
return "0"
|
||||
}
|
||||
|
||||
// withPreflightVersion 在摘要上填入版本哈希后返回(GET / confirm 都用)。
|
||||
func withPreflightVersion(s preflightSummary) preflightSummary {
|
||||
s.Version = computePreflightVersion(s)
|
||||
return s
|
||||
}
|
||||
|
||||
// verifyDeployPreflight 在部署时执行 #41 的防篡改确认校验。返回 (allowed, message):
|
||||
// - HEICODE_PREFLIGHT_REQUIRED=true:必须带匹配的 preflight_version;
|
||||
// - 否则:若客户端带了 preflight_version 则必须与实时状态匹配(漂移/篡改防护);未带则放行(向后兼容)。
|
||||
//
|
||||
// "匹配" = 用**当前**(模板、绑定、安全面)重算的版本等于传入版本。确认后任何对已绑资源/模板/
|
||||
// 高危面的改动都会翻转哈希并拒绝部署。
|
||||
func verifyDeployPreflight(userID int, templateID string, bindingIDs []int, providedVersion string) (bool, string) {
|
||||
required := common.GetEnvOrDefaultBool("HEICODE_PREFLIGHT_REQUIRED", false)
|
||||
providedVersion = strings.TrimSpace(providedVersion)
|
||||
if providedVersion == "" {
|
||||
if required {
|
||||
return false, "preflight confirmation required: call POST /api/heicode/preflight/confirm and pass its version as preflight_version"
|
||||
}
|
||||
return true, "" // 向后兼容:未带 version 且非强制 → 放行
|
||||
}
|
||||
summary, ok := buildPreflightSummary(userID, templateID, normalizeBindingIDs(bindingIDs))
|
||||
if !ok {
|
||||
return false, "unknown template_id"
|
||||
}
|
||||
// (a) 漂移/篡改防护:当前重算版本必须与传入一致(资源/模板安全面变化即翻转,#41 复审 #3)。
|
||||
if computePreflightVersion(summary) != providedVersion {
|
||||
return false, "preflight changed since confirmation (resources/template drifted or tampered); re-run preflight confirm and retry"
|
||||
}
|
||||
// (b) 当前仍须 Ready(#41 复审 #2):预算/agent_slot 等易变项不进版本哈希,故部署时重新校验,
|
||||
// 防止用 confirm 时的 ready 版本在余额耗尽/槽位占满后仍能启动。
|
||||
if !summary.Ready {
|
||||
return false, "preflight no longer ready (e.g. budget/agent slot); re-run preflight and resolve missing items"
|
||||
}
|
||||
// (c) 必须存在一条该版本的已确认记录(#41 复审 #1/#4):杜绝直接拿 GET version 绕过 confirm。
|
||||
exists, err := model.PreflightConfirmationExists(userID, templateID, providedVersion, common.GetTimestamp()*1000)
|
||||
if err != nil {
|
||||
return false, "failed to verify preflight confirmation"
|
||||
}
|
||||
if !exists {
|
||||
return false, "this preflight version was never confirmed (or has expired); call POST /api/heicode/preflight/confirm first"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// normalizeBindingIDs 去重 + 去非正数,保持顺序(用于 confirm/deploy 的 []int 入参)。
|
||||
func normalizeBindingIDs(in []int) []int {
|
||||
out := make([]int, 0, len(in))
|
||||
seen := map[int]bool{}
|
||||
for _, n := range in {
|
||||
if n > 0 && !seen[n] {
|
||||
seen[n] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// computePreflight 是纯函数(无 DB / 无 gin.Context),便于单测。给定模板、已解析的脱敏
|
||||
// 资源视图、用户剩余额度、tier 上限与当前在跑 agent 数,产出执行摘要。
|
||||
func computePreflight(tpl model.AgentTemplate, resources []preflightResource, invalidBindings []int,
|
||||
remainingQuota int64, quotaPerUnit float64, maxAgents, currentAgents int) preflightSummary {
|
||||
|
||||
present := map[string]bool{}
|
||||
for _, r := range resources {
|
||||
present[r.Type] = true
|
||||
}
|
||||
|
||||
// #39 缺失项:必需资源类别未绑定 + 预算不足。
|
||||
missing := make([]preflightMissing, 0)
|
||||
for _, req := range preflightRequiredKinds {
|
||||
if !present[req.kind] {
|
||||
missing = append(missing, preflightMissing{Kind: req.kind, Reason: "未绑定" + req.label})
|
||||
}
|
||||
}
|
||||
budgetInsufficient := remainingQuota <= 0
|
||||
if budgetInsufficient {
|
||||
missing = append(missing, preflightMissing{Kind: "budget", Reason: "账户可用额度不足,请充值或开通订阅"})
|
||||
}
|
||||
agentSlotFull := maxAgents > 0 && currentAgents >= maxAgents
|
||||
if agentSlotFull {
|
||||
missing = append(missing, preflightMissing{Kind: "agent_slot", Reason: "在跑 Agent 数已达上限(" + strconv.Itoa(maxAgents) + "),请先停止/删除一个"})
|
||||
}
|
||||
|
||||
// #40 高危操作(固定 enum):由已绑资源类型推导,均需审批。
|
||||
highRisk := make([]preflightHighRisk, 0)
|
||||
addRisk := func(op string) {
|
||||
highRisk = append(highRisk, preflightHighRisk{Op: op, Label: highRiskOpLabels[op], RequiresApproval: true})
|
||||
}
|
||||
if present["git"] {
|
||||
addRisk(highRiskProductionDeploy)
|
||||
}
|
||||
if present["database"] {
|
||||
addRisk(highRiskDBWrite)
|
||||
}
|
||||
if present["cloud_account"] || present["cloud_resource"] {
|
||||
addRisk(highRiskCloudDelete)
|
||||
addRisk(highRiskProductionSecret)
|
||||
}
|
||||
// 预算是标准确认项:启动前用户须确认本任务的预算口径。
|
||||
addRisk(highRiskLargeBudget)
|
||||
|
||||
return preflightSummary{
|
||||
TemplateID: tpl.TemplateKey,
|
||||
AgentRole: preflightRole{
|
||||
TemplateID: tpl.TemplateKey,
|
||||
Name: tpl.NameZh,
|
||||
Model: tpl.Model,
|
||||
},
|
||||
Resources: resources,
|
||||
InvalidBindings: invalidBindings,
|
||||
Missing: missing,
|
||||
HighRiskOps: highRisk,
|
||||
Budget: preflightBudget{
|
||||
RemainingQuota: remainingQuota,
|
||||
QuotaPerUnit: quotaPerUnit,
|
||||
TierMaxAgents: maxAgents,
|
||||
CurrentAgents: currentAgents,
|
||||
},
|
||||
ApprovalPolicy: gin.H{"mode": "per_high_risk_op"},
|
||||
Ready: len(missing) == 0,
|
||||
tplDigest: templateSecurityDigest(tpl),
|
||||
}
|
||||
}
|
||||
|
||||
// templateSecurityDigest 取模板安全面(definition + model + name)的短摘要,纳入版本哈希。
|
||||
func templateSecurityDigest(tpl model.AgentTemplate) string {
|
||||
sum := sha256.Sum256([]byte(tpl.Definition + "|" + tpl.Model + "|" + tpl.NameZh))
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
// parsePreflightBindingIDs 解析 binding_ids 查询参数(支持逗号分隔 "1,2,3" 或重复 key)。
|
||||
func parsePreflightBindingIDs(c *gin.Context) []int {
|
||||
raw := c.QueryArray("binding_ids")
|
||||
if len(raw) == 1 && strings.Contains(raw[0], ",") {
|
||||
raw = strings.Split(raw[0], ",")
|
||||
}
|
||||
ids := make([]int, 0, len(raw))
|
||||
seen := map[int]bool{}
|
||||
for _, s := range raw {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && n > 0 && !seen[n] {
|
||||
seen[n] = true
|
||||
ids = append(ids, n)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// buildPreflightSummary loads the template + resolved (redacted) resource views +
|
||||
// quota/tier/current-agent state for (userID, templateID, bindingIDs) and computes
|
||||
// the execution summary. Returns (summary, ok); ok=false means unknown template.
|
||||
// Shared by the GET preflight, POST confirm, and the deploy-time version check.
|
||||
func buildPreflightSummary(userID int, templateID string, bindingIDs []int) (preflightSummary, bool) {
|
||||
tpl, ok := loadAgentTemplate(templateID)
|
||||
if !ok {
|
||||
return preflightSummary{}, false
|
||||
}
|
||||
resources := make([]preflightResource, 0, len(bindingIDs))
|
||||
invalid := make([]int, 0)
|
||||
for _, id := range bindingIDs {
|
||||
var b model.ResourceBinding
|
||||
if err := model.DB.Where("id = ? AND user_id = ? AND status = ?", id, userID, "active").First(&b).Error; err != nil {
|
||||
invalid = append(invalid, id)
|
||||
continue
|
||||
}
|
||||
resources = append(resources, preflightResource{
|
||||
BindingID: b.Id,
|
||||
Type: b.ResourceType,
|
||||
Provider: b.Provider,
|
||||
Name: b.Name,
|
||||
Status: b.Status,
|
||||
HasSecret: strings.TrimSpace(b.SecretRef) != "",
|
||||
})
|
||||
}
|
||||
var remainingQuota int64
|
||||
if u, err := model.GetUserById(userID, false); err == nil && u != nil {
|
||||
remainingQuota = int64(u.Quota)
|
||||
}
|
||||
maxAgents := model.GetUserMaxAgents(userID, common.GetEnvOrDefault("HEICODE_MAX_AGENTS_PER_USER", 5))
|
||||
var currentAgents int64
|
||||
_ = model.DB.Model(&model.AgentDeployment{}).
|
||||
Where("user_id = ? AND template_id <> '' AND LOWER(status) <> ?", strconv.Itoa(userID), "stopped").
|
||||
Count(¤tAgents).Error
|
||||
return computePreflight(tpl, resources, invalid, remainingQuota, common.QuotaPerUnit, maxAgents, int(currentAgents)), true
|
||||
}
|
||||
|
||||
// HeicodePreflight: GET /api/heicode/preflight?template_id=&binding_ids=1,2,3 (#39 + #40).
|
||||
func HeicodePreflight(c *gin.Context) {
|
||||
userID := c.GetInt("id")
|
||||
if userID <= 0 {
|
||||
agentError(c, "POLICY_REJECTED", "authentication required")
|
||||
return
|
||||
}
|
||||
if model.DB == nil {
|
||||
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "database not initialised")
|
||||
return
|
||||
}
|
||||
templateID := strings.TrimSpace(c.Query("template_id"))
|
||||
if templateID == "" {
|
||||
agentError(c, "POLICY_REJECTED", "template_id is required")
|
||||
return
|
||||
}
|
||||
summary, ok := buildPreflightSummary(userID, templateID, parsePreflightBindingIDs(c))
|
||||
if !ok {
|
||||
agentError(c, "POLICY_REJECTED", "unknown template_id")
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, withPreflightVersion(summary))
|
||||
}
|
||||
|
||||
// HeicodePreflightConfirm: POST /api/heicode/preflight/confirm (#41).
|
||||
// Body: {template_id, binding_ids:[...]}. Recomputes the summary, derives a
|
||||
// tamper-proof version hash over the security-relevant (non-volatile) content,
|
||||
// and records an audit event (who / when / which version). The client passes the
|
||||
// returned `version` to POST /api/heicode/agents; deploy re-derives the version
|
||||
// from the live state and rejects if it changed (resource/template tampered or
|
||||
// drifted since confirmation).
|
||||
func HeicodePreflightConfirm(c *gin.Context) {
|
||||
userID := c.GetInt("id")
|
||||
if userID <= 0 {
|
||||
agentError(c, "POLICY_REJECTED", "authentication required")
|
||||
return
|
||||
}
|
||||
if model.DB == nil {
|
||||
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "database not initialised")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
BindingIDs []int `json:"binding_ids"`
|
||||
}
|
||||
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
agentError(c, "POLICY_REJECTED", "invalid request body")
|
||||
return
|
||||
}
|
||||
req.TemplateID = strings.TrimSpace(req.TemplateID)
|
||||
if req.TemplateID == "" {
|
||||
agentError(c, "POLICY_REJECTED", "template_id is required")
|
||||
return
|
||||
}
|
||||
summary, ok := buildPreflightSummary(userID, req.TemplateID, normalizeBindingIDs(req.BindingIDs))
|
||||
if !ok {
|
||||
agentError(c, "POLICY_REJECTED", "unknown template_id")
|
||||
return
|
||||
}
|
||||
if !summary.Ready {
|
||||
agentError(c, "POLICY_REJECTED", "preflight not ready: resolve missing items before confirming")
|
||||
return
|
||||
}
|
||||
version := computePreflightVersion(summary)
|
||||
bindingKey := model.PreflightBindingKey(req.BindingIDs)
|
||||
|
||||
// 强一致的确认记录(#41 复审 #1/#4):部署侧据此校验「该版本曾被 confirm」。**先持久化成功**
|
||||
// 才算确认;写失败直接报错(不像审计那样 best-effort),否则部署侧会因查不到记录而拒绝。
|
||||
nowMs := common.GetTimestamp() * 1000
|
||||
ttlSec := common.GetEnvOrDefault("HEICODE_PREFLIGHT_CONFIRMATION_TTL_SECONDS", 3600)
|
||||
expiresAtMs := int64(0)
|
||||
if ttlSec > 0 {
|
||||
expiresAtMs = nowMs + int64(ttlSec)*1000
|
||||
}
|
||||
if err := model.InsertPreflightConfirmation(&model.PreflightConfirmation{
|
||||
UserID: userID,
|
||||
TemplateID: req.TemplateID,
|
||||
BindingKey: bindingKey,
|
||||
Version: version,
|
||||
CreatedAtMs: nowMs,
|
||||
ExpiresAtMs: expiresAtMs,
|
||||
}); err != nil {
|
||||
common.SysLog("preflight confirm persist failed: " + err.Error())
|
||||
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist preflight confirmation")
|
||||
return
|
||||
}
|
||||
|
||||
// 附加审计流:谁、何时、确认了哪个版本(best-effort,与上面的强一致记录互补)。
|
||||
detail, _ := common.Marshal(gin.H{
|
||||
"version": version,
|
||||
"template_id": req.TemplateID,
|
||||
"binding_key": bindingKey,
|
||||
})
|
||||
model.InsertAgentAuditEvent(&model.AgentAuditEvent{
|
||||
Event: "preflight.confirmed",
|
||||
Actor: strconv.Itoa(userID),
|
||||
UserID: strconv.Itoa(userID),
|
||||
Resource: "template:" + req.TemplateID,
|
||||
Result: "ok",
|
||||
DetailsJSON: string(detail),
|
||||
})
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"version": version,
|
||||
"template_id": req.TemplateID,
|
||||
"summary": withPreflightVersion(summary),
|
||||
"confirmed": true,
|
||||
})
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/heicode/manager/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func tpl() model.AgentTemplate {
|
||||
return model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "opus"}
|
||||
}
|
||||
|
||||
// #39: 全部必需类别缺失 + 预算不足 → missing 覆盖各项,ready=false。
|
||||
func TestComputePreflight_AllMissing(t *testing.T) {
|
||||
s := computePreflight(tpl(), nil, nil, 0, 500000, 5, 0)
|
||||
kinds := map[string]bool{}
|
||||
for _, m := range s.Missing {
|
||||
kinds[m.Kind] = true
|
||||
}
|
||||
require.True(t, kinds["git"])
|
||||
require.True(t, kinds["sk"])
|
||||
require.True(t, kinds["project_document"])
|
||||
require.True(t, kinds["cloud_account"])
|
||||
require.True(t, kinds["budget"], "余额为 0 应报 budget 缺失")
|
||||
require.False(t, kinds["agent_slot"], "0/5 未满,不应报 agent_slot")
|
||||
require.False(t, s.Ready)
|
||||
}
|
||||
|
||||
// #39: 全部齐备 + 有余额 + 槽位未满 → ready=true。
|
||||
func TestComputePreflight_Ready(t *testing.T) {
|
||||
res := []preflightResource{
|
||||
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
|
||||
{BindingID: 2, Type: "sk", Provider: "custom", Name: "sk-pack", Status: "active", HasSecret: true},
|
||||
{BindingID: 3, Type: "project_document", Provider: "custom", Name: "doc", Status: "active"},
|
||||
{BindingID: 4, Type: "cloud_account", Provider: "azure", Name: "sub", Status: "active", HasSecret: true},
|
||||
}
|
||||
s := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 1)
|
||||
require.Empty(t, s.Missing)
|
||||
require.True(t, s.Ready)
|
||||
}
|
||||
|
||||
// #39: agent 槽位已满 → ready=false + agent_slot 缺失项。
|
||||
func TestComputePreflight_AgentSlotFull(t *testing.T) {
|
||||
res := []preflightResource{
|
||||
{BindingID: 1, Type: "git"}, {BindingID: 2, Type: "sk"},
|
||||
{BindingID: 3, Type: "project_document"}, {BindingID: 4, Type: "cloud_account"},
|
||||
}
|
||||
s := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 5)
|
||||
require.False(t, s.Ready)
|
||||
found := false
|
||||
for _, m := range s.Missing {
|
||||
if m.Kind == "agent_slot" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
require.True(t, found)
|
||||
}
|
||||
|
||||
// #40: 高危操作只用固定 enum,并按已绑资源类型推导。
|
||||
func TestComputePreflight_HighRiskEnum(t *testing.T) {
|
||||
res := []preflightResource{
|
||||
{BindingID: 1, Type: "git"},
|
||||
{BindingID: 2, Type: "database"},
|
||||
{BindingID: 3, Type: "cloud_account"},
|
||||
}
|
||||
s := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 0)
|
||||
ops := map[string]bool{}
|
||||
for _, h := range s.HighRiskOps {
|
||||
require.Contains(t, highRiskOpLabels, h.Op, "high-risk op 必须是固定 enum")
|
||||
require.True(t, h.RequiresApproval)
|
||||
ops[h.Op] = true
|
||||
}
|
||||
require.True(t, ops[highRiskProductionDeploy]) // git
|
||||
require.True(t, ops[highRiskDBWrite]) // database
|
||||
require.True(t, ops[highRiskCloudDelete]) // cloud_account
|
||||
require.True(t, ops[highRiskProductionSecret]) // cloud_account
|
||||
require.True(t, ops[highRiskLargeBudget]) // 标准确认项
|
||||
}
|
||||
|
||||
// #41: 版本哈希对稳定安全面确定且稳定;不随易变预算/在跑数变化。
|
||||
func TestComputePreflightVersion_StableAndDeterministic(t *testing.T) {
|
||||
res := []preflightResource{
|
||||
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
|
||||
{BindingID: 2, Type: "sk", Provider: "custom", Name: "sk", Status: "active", HasSecret: true},
|
||||
}
|
||||
s1 := computePreflight(tpl(), res, nil, 1_000_000, 500000, 5, 1)
|
||||
s2 := computePreflight(tpl(), res, nil, 7_777_777, 500000, 5, 3) // 预算/在跑数不同
|
||||
v1 := computePreflightVersion(s1)
|
||||
require.Equal(t, v1, computePreflightVersion(s2), "版本不应随易变的预算/在跑数变化")
|
||||
require.True(t, len(v1) > 5 && v1[:5] == "pfv1_")
|
||||
|
||||
// 资源顺序不影响版本(内部排序)
|
||||
resReordered := []preflightResource{res[1], res[0]}
|
||||
require.Equal(t, v1, computePreflightVersion(computePreflight(tpl(), resReordered, nil, 1, 500000, 5, 0)))
|
||||
}
|
||||
|
||||
// #41: 资源篡改(改 has_secret / 增删资源)→ 版本翻转。
|
||||
func TestComputePreflightVersion_ChangesOnTamper(t *testing.T) {
|
||||
base := computePreflight(tpl(), []preflightResource{
|
||||
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
|
||||
}, nil, 1_000_000, 500000, 5, 0)
|
||||
v0 := computePreflightVersion(base)
|
||||
|
||||
tampered := computePreflight(tpl(), []preflightResource{
|
||||
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: false},
|
||||
}, nil, 1_000_000, 500000, 5, 0)
|
||||
require.NotEqual(t, v0, computePreflightVersion(tampered), "改 has_secret 应翻转版本")
|
||||
|
||||
added := computePreflight(tpl(), []preflightResource{
|
||||
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
|
||||
{BindingID: 2, Type: "database", Provider: "postgres", Name: "db", Status: "active", HasSecret: true},
|
||||
}, nil, 1_000_000, 500000, 5, 0)
|
||||
require.NotEqual(t, v0, computePreflightVersion(added), "新增资源(引入 db_write 高危)应翻转版本")
|
||||
}
|
||||
|
||||
// #41 复审 #3:模板安全面(definition/model/name)变化 → 版本翻转(同一 template_key 被改也失效)。
|
||||
func TestComputePreflightVersion_ChangesOnTemplateEdit(t *testing.T) {
|
||||
res := []preflightResource{
|
||||
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
|
||||
}
|
||||
base := computePreflight(model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "opus", Definition: "v1 body"}, res, nil, 1_000_000, 500000, 5, 0)
|
||||
v0 := computePreflightVersion(base)
|
||||
|
||||
editedDef := computePreflight(model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "opus", Definition: "v2 body changed"}, res, nil, 1_000_000, 500000, 5, 0)
|
||||
require.NotEqual(t, v0, computePreflightVersion(editedDef), "改 definition 应翻转版本")
|
||||
|
||||
editedModel := computePreflight(model.AgentTemplate{TemplateKey: "architect", NameZh: "架构顾问", Model: "sonnet", Definition: "v1 body"}, res, nil, 1_000_000, 500000, 5, 0)
|
||||
require.NotEqual(t, v0, computePreflightVersion(editedModel), "改 model 应翻转版本")
|
||||
}
|
||||
|
||||
// #41: normalizeBindingIDs 去重 + 去非正数 + 保序。
|
||||
func TestNormalizeBindingIDs(t *testing.T) {
|
||||
require.Equal(t, []int{3, 1, 2}, normalizeBindingIDs([]int{3, 1, 3, 0, 2, -5, 1}))
|
||||
require.Empty(t, normalizeBindingIDs(nil))
|
||||
}
|
||||
|
||||
// #40 红线:resource 视图序列化后绝不含 secret_ref/channel_id/base_url/price。
|
||||
func TestComputePreflight_NoSensitiveFieldsLeaked(t *testing.T) {
|
||||
res := []preflightResource{
|
||||
{BindingID: 1, Type: "git", Provider: "github", Name: "repo", Status: "active", HasSecret: true},
|
||||
}
|
||||
s := computePreflight(tpl(), res, []int{99}, 1_000_000, 500000, 5, 0)
|
||||
b, err := json.Marshal(s)
|
||||
require.NoError(t, err)
|
||||
out := string(b)
|
||||
for _, banned := range []string{"secret_ref", "channel_id", "channelId", "base_url", "baseUrl", "price"} {
|
||||
require.NotContains(t, out, banned, "执行摘要不得暴露敏感字段: "+banned)
|
||||
}
|
||||
require.Contains(t, out, "\"has_secret\":true") // 只暴露布尔
|
||||
require.Contains(t, out, "\"invalid_bindings\":[99]")
|
||||
}
|
||||
@@ -1,504 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HM-side Swarm Run query (#45 Phase1: list / status / events?after / artifacts / stop).
|
||||
//
|
||||
// 设计要点(关键):**只读查询全部基于 HM 本地已持久化的数据**——Swarm 运行时通过带签名回调
|
||||
// (`/api/agent/callbacks/runtime-events`,见 agent_callback.go)把生命周期事件推给 HM,HM 落
|
||||
// 到 AgentDeployment + AgentCallbackEvent。因此 list/status/events/artifacts **无需**调用 Swarm
|
||||
// 运行时,也就**不依赖 `agent_swarm#2` 尚未冻结的拉取契约**,返工风险低。
|
||||
//
|
||||
// 仅 `stop`(写操作)需要真正调用 Swarm 运行时;在契约冻结 + `SWARM_RUNTIME_ENABLED=true` 前
|
||||
// 默认关闭并明确提示(不 mock,不臆造未冻结的写接口)。
|
||||
//
|
||||
// 字段口径对齐 agent_swarm/docs/integration/runtime-contract.md(草案):
|
||||
// deployment_id ↔ swarm_id ↔ manager_deployment_id 三者映射;事件按 swarm_id/deployment_id 持久化。
|
||||
|
||||
// findUserSwarmDeployment 按 :id(匹配 deployment_id / runtime_swarm_id / correlation_id)加载
|
||||
// 当前用户的 swarm 部署。非本人或非 swarm 模式 → 失败。
|
||||
func findUserSwarmDeployment(c *gin.Context) (model.AgentDeployment, bool) {
|
||||
userID := c.GetInt("id")
|
||||
id := strings.TrimSpace(c.Param("id"))
|
||||
var dep model.AgentDeployment
|
||||
err := model.DB.Where(
|
||||
"user_id = ? AND (deployment_id = ? OR runtime_swarm_id = ? OR correlation_id = ?)",
|
||||
strconv.Itoa(userID), id, id, id,
|
||||
).First(&dep).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
agentError(c, "POLICY_REJECTED", "swarm run not found")
|
||||
} else {
|
||||
agentError(c, "DEPLOYMENT_CONFLICT", "failed to load swarm run")
|
||||
}
|
||||
return model.AgentDeployment{}, false
|
||||
}
|
||||
if !isSwarmDeployment(dep) {
|
||||
agentError(c, "POLICY_REJECTED", "deployment is not a swarm run")
|
||||
return model.AgentDeployment{}, false
|
||||
}
|
||||
return dep, true
|
||||
}
|
||||
|
||||
func isSwarmDeployment(dep model.AgentDeployment) bool {
|
||||
return strings.EqualFold(dep.SubMode, "swarm") || strings.TrimSpace(dep.RuntimeSwarmID) != ""
|
||||
}
|
||||
|
||||
// swarmDeploymentView 是 swarm 运行的状态摘要视图(脱敏:不含 plan/payload 等大字段与凭据)。
|
||||
func swarmDeploymentView(dep model.AgentDeployment) gin.H {
|
||||
return gin.H{
|
||||
"deployment_id": dep.DeploymentID,
|
||||
"swarm_id": dep.RuntimeSwarmID,
|
||||
"runtime_deployment_id": dep.RuntimeDeploymentID,
|
||||
"correlation_id": dep.CorrelationID,
|
||||
"status": dep.Status, // 运行时真实状态(契约 §4)
|
||||
"display_status": swarmDisplayStatus(dep.Status), // 客户端展示态(契约 §4.1 映射)
|
||||
"goal_summary": swarmGoalSummary(dep.PlanJSON), // 单行目标(客户端 Run 列表展示;@Mem0ried #28 消费需求)
|
||||
"phase": dep.Phase,
|
||||
"runtime_state": dep.RuntimeState,
|
||||
"failure_reason": dep.FailureReason,
|
||||
"created_at": dep.CreatedAtText,
|
||||
"updated_at": dep.UpdatedAtText,
|
||||
"runtime_last_sync_at": dep.RuntimeLastSyncAtText,
|
||||
}
|
||||
}
|
||||
|
||||
// swarmGoalSummary 从持久化的 plan_json 提取顶层 objective,折叠为单行供客户端 Run 列表展示
|
||||
// (#28 消费需求:列表不止 deployment_id/status)。空白折叠 + 截断 + RedactText 兜底(objective
|
||||
// 是用户目标文本而非密钥,但仍按统一红线剥离误入的 sk-/token);取不到则返回空串(不臆造)。
|
||||
func swarmGoalSummary(planJSON string) string {
|
||||
if strings.TrimSpace(planJSON) == "" {
|
||||
return ""
|
||||
}
|
||||
var plan struct {
|
||||
Objective string `json:"objective"`
|
||||
}
|
||||
if err := common.UnmarshalJsonStr(planJSON, &plan); err != nil {
|
||||
return ""
|
||||
}
|
||||
s := strings.TrimSpace(plan.Objective)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
s = strings.Join(strings.Fields(s), " ") // 折叠所有空白(含换行)为单空格
|
||||
const maxLen = 200
|
||||
if len([]rune(s)) > maxLen {
|
||||
s = strings.TrimSpace(string([]rune(s)[:maxLen])) + "…"
|
||||
}
|
||||
return model.RedactText(s)
|
||||
}
|
||||
|
||||
// swarmDisplayStatus 把运行时真实状态映射到客户端展示态(agent_swarm runtime-contract §4.1)。
|
||||
// 运行时只有 waiting_approval/running/blocked/completed/failed/stopped;`blocked` 展示为
|
||||
// `degraded`,其余直通。preparing/verifying 是 running 的子态、由运行时阶段决定,HM 未单独存,
|
||||
// 不臆造(规则:无信号不造态)。
|
||||
func swarmDisplayStatus(status string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(status), "blocked") {
|
||||
return "degraded"
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// swarmSensitivePayloadKeys 是事件 payload 中**绝不下发**给客户端的键(凭据/大字段)。
|
||||
// 递归剔除(#45 复审 #1:回调 envelope 可能含 secret_ref,如 approval.requested)。
|
||||
var swarmSensitivePayloadKeys = map[string]bool{
|
||||
"secret_ref": true, "secretref": true, "credentials": true, "credential": true,
|
||||
// agent_swarm event-schema v1(#15):envelope 按设计透传 azkv:// 引用,客户端可见视图须剔除。
|
||||
"credential_ref": true, "signing_secret_ref": true,
|
||||
"secret": true, "token": true, "access_token": true, "refresh_token": true,
|
||||
"api_key": true, "apikey": true, "private_key": true, "access_key": true,
|
||||
"password": true, "passwd": true,
|
||||
// 大字段/内部结构,避免顺带泄漏
|
||||
"plan": true, "payload": true, "permission_manifest": true, "env": true,
|
||||
}
|
||||
|
||||
// stripSensitiveKeys 递归删除敏感键(键名小写匹配 swarmSensitivePayloadKeys)。
|
||||
func stripSensitiveKeys(v any) any {
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
out := make(map[string]any, len(t))
|
||||
for k, val := range t {
|
||||
if swarmSensitivePayloadKeys[strings.ToLower(strings.TrimSpace(k))] {
|
||||
continue
|
||||
}
|
||||
out[k] = stripSensitiveKeys(val)
|
||||
}
|
||||
return out
|
||||
case []any:
|
||||
out := make([]any, 0, len(t))
|
||||
for _, item := range t {
|
||||
out = append(out, stripSensitiveKeys(item))
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeSwarmPayload 递归剔除敏感/大字段键,再对序列化结果跑一次 RedactText 兜底
|
||||
// (剥离 sk-/Bearer/URL token/JSON 密钥字段)。
|
||||
func sanitizeSwarmPayload(raw string) map[string]any {
|
||||
m := unmarshalResourceJSON(raw)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
cleaned, _ := stripSensitiveKeys(m).(map[string]any)
|
||||
if b, err := common.Marshal(cleaned); err == nil {
|
||||
var out map[string]any
|
||||
if err := common.UnmarshalJsonStr(model.RedactText(string(b)), &out); err == nil {
|
||||
return out
|
||||
}
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// swarmEventView maps a persisted callback event to the client view (payload 脱敏)。
|
||||
func swarmEventView(e model.AgentCallbackEvent) gin.H {
|
||||
return gin.H{
|
||||
"id": e.Id, // HM 不透明分页游标(next_after);单调,兼容 sequence 未上线
|
||||
"sequence": e.Sequence, // agent_swarm v1 per-swarm 序号(客户端去重/排序;0=envelope 未带)
|
||||
"event_id": e.EventID,
|
||||
"event_type": e.EventType,
|
||||
"task_id": e.TaskID,
|
||||
"agent_instance_id": e.AgentInstanceID,
|
||||
"result": e.Result,
|
||||
"occurred_at": e.OccurredAt,
|
||||
"created_at_ms": e.CreatedAtMs,
|
||||
"payload": sanitizeSwarmPayload(e.PayloadJSON),
|
||||
}
|
||||
}
|
||||
|
||||
// HeicodeListSwarms: GET /api/heicode/swarms — 列出当前用户的 swarm 运行(读 HM 本地部署表)。
|
||||
func HeicodeListSwarms(c *gin.Context) {
|
||||
userID := c.GetInt("id")
|
||||
if userID <= 0 {
|
||||
agentError(c, "POLICY_REJECTED", "authentication required")
|
||||
return
|
||||
}
|
||||
if model.DB == nil {
|
||||
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "database not initialised")
|
||||
return
|
||||
}
|
||||
var rows []model.AgentDeployment
|
||||
err := model.DB.Where(
|
||||
"user_id = ? AND (LOWER(sub_mode) = ? OR runtime_swarm_id <> '')",
|
||||
strconv.Itoa(userID), "swarm",
|
||||
).Order("created_at_ms desc, id desc").Limit(200).Find(&rows).Error
|
||||
if err != nil {
|
||||
agentError(c, "DEPLOYMENT_CONFLICT", "failed to list swarm runs")
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
items = append(items, swarmDeploymentView(r))
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
// HeicodeGetSwarmStatus: GET /api/heicode/swarms/:id — 单个 swarm 运行状态。
|
||||
func HeicodeGetSwarmStatus(c *gin.Context) {
|
||||
dep, ok := findUserSwarmDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, swarmDeploymentView(dep))
|
||||
}
|
||||
|
||||
// HeicodeListSwarmEvents: GET /api/heicode/swarms/:id/events?after=&limit= — 事件增量拉取。
|
||||
func HeicodeListSwarmEvents(c *gin.Context) {
|
||||
dep, ok := findUserSwarmDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
after, _ := strconv.Atoi(strings.TrimSpace(c.Query("after")))
|
||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
|
||||
events, err := model.ListSwarmCallbackEventsAfter(strconv.Itoa(c.GetInt("id")), dep.DeploymentID, dep.RuntimeSwarmID, after, limit)
|
||||
if err != nil {
|
||||
agentError(c, "DEPLOYMENT_CONFLICT", "failed to list swarm events")
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0, len(events))
|
||||
nextAfter := after
|
||||
for _, e := range events {
|
||||
items = append(items, swarmEventView(e))
|
||||
if e.Id > nextAfter {
|
||||
nextAfter = e.Id
|
||||
}
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"items": items, "next_after": nextAfter, "count": len(items)})
|
||||
}
|
||||
|
||||
// HeicodeListSwarmArtifacts: GET /api/heicode/swarms/:id/artifacts — 从已持久化事件中筛产物。
|
||||
// 当前从 event_type 含 "artifact" 的回调事件派生(真实数据);专用 artifact 端点待 agent_swarm#2 冻结后补。
|
||||
func HeicodeListSwarmArtifacts(c *gin.Context) {
|
||||
dep, ok := findUserSwarmDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
events, err := model.ListSwarmCallbackEventsAfter(strconv.Itoa(c.GetInt("id")), dep.DeploymentID, dep.RuntimeSwarmID, 0, 1000)
|
||||
if err != nil {
|
||||
agentError(c, "DEPLOYMENT_CONFLICT", "failed to list swarm artifacts")
|
||||
return
|
||||
}
|
||||
items := make([]gin.H, 0)
|
||||
for _, e := range events {
|
||||
if strings.Contains(strings.ToLower(e.EventType), "artifact") {
|
||||
items = append(items, swarmArtifactView(e))
|
||||
}
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
// swarmArtifactView 从 artifact.created 事件产出客户端扁平视图(agent_swarm event-schema v1):
|
||||
// {uri, checksum, task_id, size_bytes?, created_at}。值取自(已脱敏的)payload —— uri/checksum
|
||||
// 非敏感保留;**绝不含 secret_ref**。size 未知则省略(不伪造,契约要求)。
|
||||
func swarmArtifactView(e model.AgentCallbackEvent) gin.H {
|
||||
p := sanitizeSwarmPayload(e.PayloadJSON)
|
||||
get := func(k string) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return p[k]
|
||||
}
|
||||
createdAt := get("created_at")
|
||||
if createdAt == nil || createdAt == "" {
|
||||
createdAt = e.OccurredAt
|
||||
}
|
||||
out := gin.H{
|
||||
"event_id": e.EventID,
|
||||
"sequence": e.Sequence,
|
||||
"task_id": firstNonNil(get("task_id"), e.TaskID),
|
||||
"uri": get("uri"),
|
||||
"checksum": get("checksum"),
|
||||
"created_at": createdAt,
|
||||
}
|
||||
if sz := get("size_bytes"); sz != nil { // 未知则省略,不伪造
|
||||
out["size_bytes"] = sz
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// firstNonNil 返回第一个非 nil/非空的值。
|
||||
func firstNonNil(vals ...any) any {
|
||||
for _, v := range vals {
|
||||
if v != nil && v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HeicodeStopSwarm: POST /api/heicode/swarms/:id/stop — 停止 swarm 运行(写操作)。
|
||||
// 这是唯一需要真正调用 Swarm 运行时的操作。契约已随 agent_swarm runtime-contract v1 冻结
|
||||
// (#45 复审 + agent_swarm#14):POST /api/agent/swarm/deployments/{deployment_id}/stop,
|
||||
// Bearer SWARM_RUNTIME_SERVICE_TOKEN,X-Idempotency-Key 幂等;运行时受理后异步停机并回推
|
||||
// swarm.stopped 事件。开关 SWARM_RUNTIME_ENABLED=false(默认)或缺 base_url/token 时一律拒绝,
|
||||
// **绝不伪造 accepted**(#45 复审 #2)。
|
||||
func HeicodeStopSwarm(c *gin.Context) {
|
||||
dep, ok := findUserSwarmDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeSwarm)
|
||||
// 开关 + base_url + 服务令牌缺一不可:任一缺失都不调用、不伪造受理。
|
||||
if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Token) == "" {
|
||||
agentError(c, "POLICY_REJECTED",
|
||||
"swarm stop not enabled: requires SWARM_RUNTIME_ENABLED=true with SWARM_RUNTIME_BASE_URL and SWARM_RUNTIME_SERVICE_TOKEN")
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
result, err := callSwarmRuntimeStop(c.Request.Context(), cfg, dep, body.Reason)
|
||||
if err != nil {
|
||||
common.SysLog("HeicodeStopSwarm: " + err.Error())
|
||||
agentError(c, "RUNTIME_UNAVAILABLE", "failed to stop swarm run at runtime: "+err.Error())
|
||||
return
|
||||
}
|
||||
// 运行时已受理停机;真正终态由运行时异步回推 swarm.stopped(经 /api/agent/callbacks)写回,
|
||||
// HM 此处不抢先改写 dep.Status 以免与回调竞态。只落审计。
|
||||
recordAgentAuditEvent(agentEvent{
|
||||
EventID: "evt_" + common.GetUUID()[:12],
|
||||
Event: "swarm.stop_requested",
|
||||
SchemaVersion: 1,
|
||||
UserID: dep.UserID,
|
||||
ChannelID: dep.ChannelID,
|
||||
BindingScope: dep.BindingScope,
|
||||
DeploymentID: dep.DeploymentID,
|
||||
CorrelationID: dep.CorrelationID,
|
||||
OccurredAt: agentNow(),
|
||||
}, "agent_swarm_query", dep.DeploymentID, agentRequestID(c), "ok")
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"deployment_id": dep.DeploymentID,
|
||||
"swarm_id": dep.RuntimeSwarmID,
|
||||
"accepted": true,
|
||||
"runtime_status": firstNonEmpty(result.RuntimeStatus, "stopping"),
|
||||
"note": "stop accepted by runtime; final state arrives via swarm.stopped callback",
|
||||
})
|
||||
}
|
||||
|
||||
// swarmStopResult 是运行时 stop 调用的归一化结果。
|
||||
type swarmStopResult struct {
|
||||
RuntimeStatus string
|
||||
HTTPStatus int
|
||||
}
|
||||
|
||||
// callSwarmRuntimeStop 向 Swarm 运行时发起真实 stop(runtime-contract v1)。复用 agent_runtime_client
|
||||
// 的配置/URL/信封解析 helper,但按 model.AgentDeployment 直接构造请求(无需 agentDeploymentRecord)。
|
||||
// runtime id 取 runtime_deployment_id,缺失时回退 runtime_swarm_id(契约三映射)。
|
||||
func callSwarmRuntimeStop(ctx context.Context, cfg agentRuntimeConfig, dep model.AgentDeployment, reason string) (swarmStopResult, error) {
|
||||
runtimeID := firstNonEmpty(strings.TrimSpace(dep.RuntimeDeploymentID), strings.TrimSpace(dep.RuntimeSwarmID))
|
||||
if runtimeID == "" {
|
||||
return swarmStopResult{}, errors.New("swarm run has no runtime deployment id yet")
|
||||
}
|
||||
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeStopPath(cfg, runtimeID))
|
||||
if err != nil {
|
||||
return swarmStopResult{}, err
|
||||
}
|
||||
payload, err := common.Marshal(gin.H{
|
||||
"reason": firstNonEmpty(strings.TrimSpace(reason), "Heicode Manager requested stop"),
|
||||
"manager_deployment_id": dep.DeploymentID,
|
||||
})
|
||||
if err != nil {
|
||||
return swarmStopResult{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return swarmStopResult{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(cfg.Token))
|
||||
// 幂等键固定按 manager deployment_id 派生:同一 stop 重试不会在运行时侧重复执行(契约要求)。
|
||||
req.Header.Set("X-Idempotency-Key", "manager-stop-"+dep.DeploymentID)
|
||||
if cid := strings.TrimSpace(dep.CorrelationID); cid != "" {
|
||||
req.Header.Set("X-Correlation-ID", cid)
|
||||
}
|
||||
client := &http.Client{Timeout: cfg.Timeout}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return swarmStopResult{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if readErr != nil {
|
||||
return swarmStopResult{HTTPStatus: resp.StatusCode}, readErr
|
||||
}
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return swarmStopResult{HTTPStatus: resp.StatusCode}, fmt.Errorf("runtime stop returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
var envelope map[string]any
|
||||
if len(respBody) > 0 {
|
||||
if err := common.Unmarshal(respBody, &envelope); err != nil {
|
||||
return swarmStopResult{HTTPStatus: resp.StatusCode}, err
|
||||
}
|
||||
}
|
||||
if message := agentRuntimeEnvelopeError(envelope); message != "" {
|
||||
return swarmStopResult{HTTPStatus: resp.StatusCode}, errors.New(message)
|
||||
}
|
||||
data := extractAgentRuntimeData(envelope)
|
||||
return swarmStopResult{
|
||||
RuntimeStatus: stringFromMap(data, "runtime_status", "status"),
|
||||
HTTPStatus: resp.StatusCode,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// swarmTerminalEventTypes 是 agent_swarm event-schema v1(#15)的运行终态事件。命中即可结束 SSE 流。
|
||||
var swarmTerminalEventTypes = map[string]bool{
|
||||
"swarm.completed": true,
|
||||
"swarm.failed": true,
|
||||
"swarm.stopped": true,
|
||||
}
|
||||
|
||||
func swarmEventIsTerminal(eventType string) bool {
|
||||
return swarmTerminalEventTypes[strings.ToLower(strings.TrimSpace(eventType))]
|
||||
}
|
||||
|
||||
// HeicodeStreamSwarmEvents: GET /api/heicode/swarms/:id/events/stream?after= — SSE 实时事件流。
|
||||
//
|
||||
// 与 §events?after 同源:**全部来自 HM 已持久化的回调事件**(model.ListSwarmCallbackEventsAfter,
|
||||
// 按 user_id 收口防跨用户泄漏),服务端轮询新事件后以 SSE 帧推送,**无需调用 Swarm 运行时**——
|
||||
// 因此与 stop 不同,不受 SWARM_RUNTIME_ENABLED 限制。事件 payload 经 swarmEventView 脱敏。
|
||||
// 进入即回放 after 之后的历史(免去客户端先调 events?after 再订阅);命中终态事件
|
||||
// (swarm.completed/failed/stopped)、客户端断开或超过 maxLifetime 即结束。
|
||||
func HeicodeStreamSwarmEvents(c *gin.Context) {
|
||||
dep, ok := findUserSwarmDeployment(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
userID := strconv.Itoa(c.GetInt("id"))
|
||||
after, _ := strconv.Atoi(strings.TrimSpace(c.Query("after")))
|
||||
|
||||
c.Writer.Header().Set("Content-Type", "text/event-stream")
|
||||
c.Writer.Header().Set("Cache-Control", "no-cache")
|
||||
c.Writer.Header().Set("Connection", "keep-alive")
|
||||
c.Writer.Header().Set("X-Accel-Buffering", "no") // 关掉反代缓冲,逐帧下发
|
||||
|
||||
const (
|
||||
pollInterval = 1500 * time.Millisecond
|
||||
maxLifetime = 30 * time.Minute
|
||||
)
|
||||
deadline := time.Now().Add(maxLifetime)
|
||||
ticker := time.NewTicker(pollInterval)
|
||||
defer ticker.Stop()
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// flush 推送 after 之后的新事件,返回 false 表示已命中终态(应结束流)或读失败。
|
||||
flush := func() bool {
|
||||
events, err := model.ListSwarmCallbackEventsAfter(userID, dep.DeploymentID, dep.RuntimeSwarmID, after, 200)
|
||||
if err != nil {
|
||||
c.SSEvent("error", gin.H{"message": "failed to read swarm events"})
|
||||
c.Writer.Flush()
|
||||
return false
|
||||
}
|
||||
terminal := false
|
||||
for _, e := range events {
|
||||
c.SSEvent("message", swarmEventView(e))
|
||||
if e.Id > after {
|
||||
after = e.Id
|
||||
}
|
||||
if swarmEventIsTerminal(e.EventType) {
|
||||
terminal = true
|
||||
}
|
||||
}
|
||||
c.Writer.Flush()
|
||||
return !terminal
|
||||
}
|
||||
if !flush() {
|
||||
c.SSEvent("done", gin.H{"next_after": after, "reason": "terminal"})
|
||||
c.Writer.Flush()
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done(): // 客户端断开
|
||||
return
|
||||
case <-ticker.C:
|
||||
if time.Now().After(deadline) {
|
||||
c.SSEvent("done", gin.H{"next_after": after, "reason": "timeout"})
|
||||
c.Writer.Flush()
|
||||
return
|
||||
}
|
||||
if !flush() {
|
||||
c.SSEvent("done", gin.H{"next_after": after, "reason": "terminal"})
|
||||
c.Writer.Flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/heicode/manager/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// #45 复审 #1:事件 payload 必须脱敏 —— 递归剔除 secret_ref / credentials / 大字段,
|
||||
// 并对结果再跑 RedactText 兜底,绝不把 azkv:// secret_ref 或 sk-/Bearer 下发给客户端。
|
||||
func TestSanitizeSwarmPayload_StripsSecrets(t *testing.T) {
|
||||
raw := `{
|
||||
"task_id":"t1",
|
||||
"approval":{"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/git-pat","note":"deploy"},
|
||||
"credentials":{"access_key":"AKIA123","secret_access_key":"xxx"},
|
||||
"headers":{"authorization":"Bearer aZ09tokenVALUE"},
|
||||
"api_key":"sk-abcDEF1234567890",
|
||||
"stack":["plain frame","key=sk-leak0987654321ABCD"],
|
||||
"ok":true
|
||||
}`
|
||||
out := sanitizeSwarmPayload(raw)
|
||||
b, err := json.Marshal(out)
|
||||
require.NoError(t, err)
|
||||
s := string(b)
|
||||
|
||||
// 敏感键被递归剔除
|
||||
require.NotContains(t, s, "secret_ref")
|
||||
require.NotContains(t, s, "azkv://")
|
||||
require.NotContains(t, s, "git-pat")
|
||||
require.NotContains(t, s, "credentials")
|
||||
require.NotContains(t, s, "AKIA123")
|
||||
require.NotContains(t, s, "api_key")
|
||||
// RedactText 兜底:残留在普通字段里的 sk-/Bearer 也被打码
|
||||
require.NotContains(t, s, "sk-leak0987654321ABCD")
|
||||
require.NotContains(t, s, "aZ09tokenVALUE")
|
||||
// 非敏感内容保留
|
||||
require.Contains(t, s, "t1")
|
||||
require.Contains(t, s, "ok")
|
||||
}
|
||||
|
||||
func TestSanitizeSwarmPayload_EmptyAndPlain(t *testing.T) {
|
||||
require.Empty(t, sanitizeSwarmPayload(""))
|
||||
out := sanitizeSwarmPayload(`{"route":"chat","n":3}`)
|
||||
require.Equal(t, "chat", out["route"])
|
||||
}
|
||||
|
||||
// #45/§4.1: 状态展示映射 —— blocked→degraded,其余直通(不臆造 preparing/verifying)。
|
||||
func TestSwarmDisplayStatus(t *testing.T) {
|
||||
require.Equal(t, "degraded", swarmDisplayStatus("blocked"))
|
||||
require.Equal(t, "degraded", swarmDisplayStatus("BLOCKED"))
|
||||
require.Equal(t, "running", swarmDisplayStatus("running"))
|
||||
require.Equal(t, "completed", swarmDisplayStatus("completed"))
|
||||
require.Equal(t, "waiting_approval", swarmDisplayStatus("waiting_approval"))
|
||||
}
|
||||
|
||||
// #15: artifact.created → 扁平视图 {uri,checksum,task_id,created_at}(size 缺省省略);绝不泄 secret_ref。
|
||||
func TestSwarmArtifactView(t *testing.T) {
|
||||
e := model.AgentCallbackEvent{
|
||||
EventID: "evt-a1", Sequence: 5, TaskID: "t9", OccurredAt: "2026-06-10T00:00:00Z",
|
||||
PayloadJSON: `{"uri":"azblob://bucket/x.zip","checksum":"sha256:abc","task_id":"t9","secret_ref":"azkv://kv/secrets/s"}`,
|
||||
}
|
||||
v := swarmArtifactView(e)
|
||||
require.Equal(t, "azblob://bucket/x.zip", v["uri"])
|
||||
require.Equal(t, "sha256:abc", v["checksum"])
|
||||
require.Equal(t, "t9", v["task_id"])
|
||||
require.Equal(t, "2026-06-10T00:00:00Z", v["created_at"]) // payload 无 created_at → 回退 occurred_at
|
||||
_, hasSize := v["size_bytes"]
|
||||
require.False(t, hasSize, "size 未知不应出现")
|
||||
b, _ := json.Marshal(v)
|
||||
require.NotContains(t, string(b), "secret_ref")
|
||||
require.NotContains(t, string(b), "azkv://")
|
||||
|
||||
// 带 size_bytes 时保留
|
||||
e2 := model.AgentCallbackEvent{EventID: "evt-a2", PayloadJSON: `{"uri":"u","checksum":"c","size_bytes":1234,"created_at":"2026-06-10T01:00:00Z"}`}
|
||||
v2 := swarmArtifactView(e2)
|
||||
require.EqualValues(t, 1234, v2["size_bytes"])
|
||||
require.Equal(t, "2026-06-10T01:00:00Z", v2["created_at"])
|
||||
}
|
||||
|
||||
// #45/#15: SSE 终态判定 —— 仅 swarm.completed/failed/stopped 收流,其余继续。
|
||||
func TestSwarmEventIsTerminal(t *testing.T) {
|
||||
for _, et := range []string{"swarm.completed", "swarm.failed", "swarm.stopped", "SWARM.STOPPED"} {
|
||||
require.True(t, swarmEventIsTerminal(et), "%s 应为终态", et)
|
||||
}
|
||||
for _, et := range []string{"swarm.started", "handoff.created", "approval.approved", "", "running"} {
|
||||
require.False(t, swarmEventIsTerminal(et), "%s 不应为终态", et)
|
||||
}
|
||||
}
|
||||
|
||||
// #45: stop 真实接入 —— 命中冻结契约路径/鉴权/幂等头,并解析 {success,data} 信封。
|
||||
func TestCallSwarmRuntimeStop(t *testing.T) {
|
||||
var gotPath, gotAuth, gotIdem, gotCorr string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
gotIdem = r.Header.Get("X-Idempotency-Key")
|
||||
gotCorr = r.Header.Get("X-Correlation-ID")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"success":true,"data":{"runtime_status":"stopping"}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
cfg := agentRuntimeConfig{
|
||||
Enabled: true,
|
||||
BaseURL: srv.URL,
|
||||
Token: "svc-token",
|
||||
StopPath: "/api/agent/swarm/deployments/{deployment_id}/stop",
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
dep := model.AgentDeployment{
|
||||
DeploymentID: "dep-1",
|
||||
RuntimeDeploymentID: "rt-9",
|
||||
RuntimeSwarmID: "sw-7",
|
||||
CorrelationID: "cor-3",
|
||||
}
|
||||
res, err := callSwarmRuntimeStop(context.Background(), cfg, dep, "user requested")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "stopping", res.RuntimeStatus)
|
||||
require.Equal(t, "/api/agent/swarm/deployments/rt-9/stop", gotPath) // runtime_deployment_id 优先
|
||||
require.Equal(t, "Bearer svc-token", gotAuth)
|
||||
require.Equal(t, "manager-stop-dep-1", gotIdem) // 幂等键按 manager deployment_id 派生
|
||||
require.Equal(t, "cor-3", gotCorr)
|
||||
}
|
||||
|
||||
// runtime_deployment_id 缺失时回退 runtime_swarm_id(契约三映射);无任何 runtime id 则报错不发请求。
|
||||
func TestCallSwarmRuntimeStop_RuntimeIDFallbackAndMissing(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
_, _ = w.Write([]byte(`{"success":true,"data":{}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
cfg := agentRuntimeConfig{
|
||||
Enabled: true, BaseURL: srv.URL, Token: "t",
|
||||
StopPath: "/api/agent/swarm/deployments/{deployment_id}/stop", Timeout: 5 * time.Second,
|
||||
}
|
||||
_, err := callSwarmRuntimeStop(context.Background(), cfg, model.AgentDeployment{DeploymentID: "d", RuntimeSwarmID: "sw-only"}, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "/api/agent/swarm/deployments/sw-only/stop", gotPath)
|
||||
|
||||
_, err = callSwarmRuntimeStop(context.Background(), cfg, model.AgentDeployment{DeploymentID: "d"}, "")
|
||||
require.Error(t, err) // 无 runtime id → 不发请求
|
||||
}
|
||||
|
||||
// 运行时非 2xx → 返回错误(handler 据此回 RUNTIME_UNAVAILABLE,不伪造受理)。
|
||||
func TestCallSwarmRuntimeStop_RuntimeError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
_, _ = w.Write([]byte(`upstream down`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
cfg := agentRuntimeConfig{
|
||||
Enabled: true, BaseURL: srv.URL, Token: "t",
|
||||
StopPath: "/api/agent/swarm/deployments/{deployment_id}/stop", Timeout: 5 * time.Second,
|
||||
}
|
||||
_, err := callSwarmRuntimeStop(context.Background(), cfg, model.AgentDeployment{DeploymentID: "d", RuntimeDeploymentID: "rt"}, "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// #15: 6 类新事件已注册(类别 + 必填字段两张表)。
|
||||
func TestSwarmFrozenEventTypesRegistered(t *testing.T) {
|
||||
for _, et := range []string{
|
||||
"swarm.completed", "swarm.failed", "swarm.stopped",
|
||||
"approval.approved", "approval.rejected", "handoff.created",
|
||||
} {
|
||||
_, inCat := agentCallbackEventCategories[et]
|
||||
_, inReq := agentCallbackEventRequiredFields[et]
|
||||
require.True(t, inCat, "event_type %s 应在 categories 注册", et)
|
||||
require.True(t, inReq, "event_type %s 应在 requiredFields 注册", et)
|
||||
}
|
||||
}
|
||||
|
||||
// #28(@Mem0ried 消费需求):goal_summary 从 plan_json 顶层 objective 提取 —— 折叠空白为单行、
|
||||
// 截断、脱敏;无 plan / 无 objective / 坏 JSON 返回空串(不臆造)。
|
||||
func TestSwarmGoalSummary(t *testing.T) {
|
||||
require.Empty(t, swarmGoalSummary(""))
|
||||
require.Empty(t, swarmGoalSummary("not-json"))
|
||||
require.Empty(t, swarmGoalSummary(`{"sub_mode":"swarm"}`)) // 无 objective
|
||||
|
||||
// 多行/多空格折叠成单行(JSON 里的 \n 是合法转义,用原始串避免再转义)
|
||||
got := swarmGoalSummary(`{"objective":" 迁移 支付服务\n 到 K8s "}`)
|
||||
require.Equal(t, "迁移 支付服务 到 K8s", got)
|
||||
|
||||
// 误入的 sk- 被 RedactText 兜底
|
||||
red := swarmGoalSummary(`{"objective":"deploy with key sk-abcdEFGH1234567890XYZ now"}`)
|
||||
require.NotContains(t, red, "sk-abcdEFGH1234567890XYZ")
|
||||
|
||||
// 超长截断带省略号
|
||||
long := `{"objective":"` + strings.Repeat("x", 400) + `"}`
|
||||
out := swarmGoalSummary(long)
|
||||
require.LessOrEqual(t, len([]rune(out)), 201) // 200 + …
|
||||
require.True(t, strings.HasSuffix(out, "…"))
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package controller
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -39,23 +38,6 @@ func sealAgentToken(token string) string {
|
||||
return sealed
|
||||
}
|
||||
|
||||
// agentTokenSealKeyConfigured reports whether a stable, explicitly-configured
|
||||
// CRYPTO_SECRET is present so the per-agent access_token can be sealed with a
|
||||
// key that survives a container restart (#31).
|
||||
//
|
||||
// Subtle but important: common.CryptoSecret is NEVER literally "" — it defaults
|
||||
// to uuid.New() (constants.go) and, when CRYPTO_SECRET is unset, falls back to
|
||||
// SessionSecret (init.go). So the "CryptoSecret unset?" plaintext fallback in
|
||||
// sealAgentToken is effectively unreachable; the real production hazard is a
|
||||
// key that is not stable across restarts. If CRYPTO_SECRET is not explicitly
|
||||
// set, the seal key may be an ephemeral random UUID, so every previously sealed
|
||||
// agent token becomes undecryptable after the next restart. We therefore gate
|
||||
// agent deployment on CRYPTO_SECRET being explicitly configured. Pure helper so
|
||||
// the policy is unit-testable without mutating process env globals.
|
||||
func agentTokenSealKeyConfigured(cryptoSecretEnv string) bool {
|
||||
return strings.TrimSpace(cryptoSecretEnv) != ""
|
||||
}
|
||||
|
||||
// unsealAgentToken reverses sealAgentToken. If the stored value is not a sealed
|
||||
// blob (legacy plaintext / CryptoSecret unset), it is returned as-is.
|
||||
func unsealAgentToken(stored string) string {
|
||||
@@ -111,7 +93,6 @@ func templateAgentResponse(row model.AgentDeployment) gin.H {
|
||||
if strings.TrimSpace(row.BindingIDsJSON) != "" {
|
||||
_ = common.UnmarshalJsonStr(row.BindingIDsJSON, &bindingIDs)
|
||||
}
|
||||
scheme, profile, secure := agentEndpointSecurity(row.Subdomain)
|
||||
return gin.H{
|
||||
"agent_id": row.DeploymentID,
|
||||
"template_id": row.TemplateID,
|
||||
@@ -122,30 +103,6 @@ func templateAgentResponse(row model.AgentDeployment) gin.H {
|
||||
"runtime_id": row.RuntimeDeploymentID,
|
||||
"created_at": row.CreatedAtText,
|
||||
"updated_at": row.UpdatedAtText,
|
||||
// #55: A2A 直连安全元数据。客户端据此在生产强制 HTTPS(HEICODE_AGENT_REQUIRE_SECURE):
|
||||
// scheme=http/https,security_profile=none/tls,secure=profile!=none。AM 启用 HTTPS/
|
||||
// mTLS listener 是 AM(azgy)的事;HM 只如实回传当前子域安全级别,明文 http →
|
||||
// security_profile=none,客户端可拒绝并提示(元数据不含任何 secret_ref)。
|
||||
"security": gin.H{
|
||||
"scheme": scheme,
|
||||
"security_profile": profile,
|
||||
"secure": secure,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// agentEndpointSecurity 从子域字符串推断 A2A 直连的安全级别(#55)。纯函数,可单测。
|
||||
// 显式 https:// → (https, tls, true);显式 http:// 或裸主机(AM 当前默认明文) → (http, none, false)。
|
||||
// 保守口径:无法确证 TLS 即视为 none,宁可让客户端在生产拒绝,也不回传"看似安全"的明文端点。
|
||||
func agentEndpointSecurity(subdomain string) (scheme, profile string, secure bool) {
|
||||
s := strings.TrimSpace(strings.ToLower(subdomain))
|
||||
switch {
|
||||
case strings.HasPrefix(s, "https://"):
|
||||
return "https", "tls", true
|
||||
case strings.HasPrefix(s, "http://"):
|
||||
return "http", "none", false
|
||||
default:
|
||||
return "http", "none", false // 裸主机:AM 当前明文 HTTP,保守标 none
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,24 +117,9 @@ func HeicodeDeployAgent(c *gin.Context) {
|
||||
agentError(c, "POLICY_REJECTED", "authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
// #31: refuse to deploy when the per-agent access_token cannot be sealed with
|
||||
// a stable key. Without an explicit CRYPTO_SECRET the seal key falls back to
|
||||
// an ephemeral random value, so the sealed token becomes undecryptable after
|
||||
// the next restart — the agent would silently lose its credential. A dev-only
|
||||
// override keeps local runs (no CRYPTO_SECRET) working; it must never be set
|
||||
// in production.
|
||||
if !agentTokenSealKeyConfigured(os.Getenv("CRYPTO_SECRET")) &&
|
||||
!common.GetEnvOrDefaultBool("HEICODE_ALLOW_PLAINTEXT_AGENT_TOKEN_IN_DEV", false) {
|
||||
common.SysLog("agent deploy rejected: CRYPTO_SECRET not set (per-agent access_token cannot be sealed with a restart-stable key)")
|
||||
agentError(c, "POLICY_REJECTED", "agent deployment is disabled until CRYPTO_SECRET is configured, so the per-agent access token can be sealed with a key that survives restarts (dev-only override: HEICODE_ALLOW_PLAINTEXT_AGENT_TOKEN_IN_DEV=true)")
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
TemplateID string `json:"template_id"`
|
||||
BindingIDs []int `json:"binding_ids"`
|
||||
PreflightVersion string `json:"preflight_version"`
|
||||
TemplateID string `json:"template_id"`
|
||||
BindingIDs []int `json:"binding_ids"`
|
||||
}
|
||||
if err := common.UnmarshalBodyReusable(c, &req); err != nil {
|
||||
agentError(c, "POLICY_REJECTED", "invalid request body")
|
||||
@@ -196,37 +138,6 @@ func HeicodeDeployAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// #41: preflight 防篡改确认校验。默认仅在客户端带了 preflight_version 时校验(向后兼容);
|
||||
// HEICODE_PREFLIGHT_REQUIRED=true 时强制要求。确认后资源/模板/高危面漂移或被篡改 → 版本不匹配 → 拒绝。
|
||||
if ok, msg := verifyDeployPreflight(userID, req.TemplateID, req.BindingIDs, req.PreflightVersion); !ok {
|
||||
agentError(c, "POLICY_REJECTED", msg)
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce the per-user deployed-agent cap (产品文档「个人5/团队8」). The cap is
|
||||
// tier-aware (#8): it is the highest MaxAgents among the user's active
|
||||
// subscription plans, falling back to env HEICODE_MAX_AGENTS_PER_USER
|
||||
// (default 5) when no active plan sets one. So 团队 8 = an admin-configured
|
||||
// team plan's MaxAgents; 个人 5 = the default — no hard-coded tier guess.
|
||||
// Only the user's own non-stopped template agents count — a stopped agent
|
||||
// consumes no runtime, so it is excluded. LOWER(status) keeps the comparison
|
||||
// portable across SQLite/MySQL/PostgreSQL. maxAgents<=0 means "unlimited"
|
||||
// (escape hatch). Closes the over-deployment hole across repeated calls.
|
||||
envMaxAgents := common.GetEnvOrDefault("HEICODE_MAX_AGENTS_PER_USER", 5)
|
||||
if maxAgents := model.GetUserMaxAgents(userID, envMaxAgents); maxAgents > 0 {
|
||||
var active int64
|
||||
if err := model.DB.Model(&model.AgentDeployment{}).
|
||||
Where("user_id = ? AND template_id <> '' AND LOWER(status) <> ?", strconv.Itoa(userID), "stopped").
|
||||
Count(&active).Error; err != nil {
|
||||
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to check agent quota")
|
||||
return
|
||||
}
|
||||
if active >= int64(maxAgents) {
|
||||
agentError(c, "POLICY_REJECTED", "agent deployment limit reached ("+strconv.Itoa(maxAgents)+"); stop or delete an existing agent first")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Load the HM-maintained template (its .md definition is sent to AM).
|
||||
tpl, ok := loadAgentTemplate(req.TemplateID)
|
||||
if !ok {
|
||||
@@ -266,36 +177,8 @@ func HeicodeDeployAgent(c *gin.Context) {
|
||||
env["AGENT_ACCESS_TOKEN"] = agentAccessToken
|
||||
env["HEICODE_AGENT_ID"] = deploymentID
|
||||
|
||||
// Persist the agent as Pending and return immediately; start it in AM in the
|
||||
// background. Starting an agent in AM takes ~30s — longer than the Azure
|
||||
// gateway's ~20s timeout — so doing it inside the request returned 504, which
|
||||
// cancelled the request context, aborted the AM call and rolled the deploy
|
||||
// back. Now the client gets the Pending agent at once and polls
|
||||
// GET /api/heicode/agents (which refreshes live status from AM), seeing
|
||||
// Pending -> running once AM is up (or -> failed if AM start failed).
|
||||
now := agentNow()
|
||||
nowMs := time.Now().UnixMilli()
|
||||
row := model.AgentDeployment{
|
||||
DeploymentID: deploymentID,
|
||||
UserID: strconv.Itoa(userID),
|
||||
TemplateID: req.TemplateID,
|
||||
AccessToken: sealAgentToken(agentAccessToken),
|
||||
BindingIDsJSON: string(bindingIDsJSON),
|
||||
Status: "Pending",
|
||||
ModelTokenID: modelTokenID,
|
||||
CreatedAtText: now,
|
||||
UpdatedAtText: now,
|
||||
CreatedAtMs: nowMs,
|
||||
UpdatedAtMs: nowMs,
|
||||
}
|
||||
if err := model.DB.Create(&row).Error; err != nil {
|
||||
common.SysLog("HeicodeDeployAgent persist: " + err.Error())
|
||||
revokeAgentModelToken(modelTokenID)
|
||||
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist agent")
|
||||
return
|
||||
}
|
||||
|
||||
startTemplateAgentAsync(deploymentID, modelTokenID, amStartArgs{
|
||||
// Ask AM to start the agent with the template definition (.md) + env injected.
|
||||
result, err := amStartTemplateAgent(c.Request.Context(), amStartArgs{
|
||||
ManagerDeploymentID: deploymentID,
|
||||
UserID: strconv.Itoa(userID),
|
||||
TemplateKey: tpl.TemplateKey,
|
||||
@@ -304,89 +187,45 @@ func HeicodeDeployAgent(c *gin.Context) {
|
||||
Env: env,
|
||||
CallbackURL: agentRuntimeCallbackURL(),
|
||||
})
|
||||
if err != nil {
|
||||
revokeAgentModelToken(modelTokenID) // don't leak the minted key
|
||||
agentError(c, "RUNTIME_UNAVAILABLE", "failed to start agent: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
now := agentNow()
|
||||
nowMs := time.Now().UnixMilli()
|
||||
row := model.AgentDeployment{
|
||||
DeploymentID: deploymentID,
|
||||
UserID: strconv.Itoa(userID),
|
||||
TemplateID: req.TemplateID,
|
||||
Subdomain: result.Subdomain,
|
||||
AccessToken: sealAgentToken(agentAccessToken),
|
||||
BindingIDsJSON: string(bindingIDsJSON),
|
||||
RuntimeDeploymentID: result.RuntimeID,
|
||||
Status: firstNonEmpty(result.Status, "running"),
|
||||
ModelTokenID: modelTokenID,
|
||||
CreatedAtText: now,
|
||||
UpdatedAtText: now,
|
||||
CreatedAtMs: nowMs,
|
||||
UpdatedAtMs: nowMs,
|
||||
}
|
||||
if err := model.DB.Create(&row).Error; err != nil {
|
||||
common.SysLog("HeicodeDeployAgent persist: " + err.Error())
|
||||
revokeAgentModelToken(modelTokenID)
|
||||
// We started an agent in AM but failed to record it: roll back the
|
||||
// orphan so it does not leak/keep running with no Manager record.
|
||||
if strings.TrimSpace(result.RuntimeID) != "" {
|
||||
if delErr := amDeleteTemplateAgent(c.Request.Context(), result.RuntimeID); delErr != nil {
|
||||
common.SysLog("HeicodeDeployAgent orphan cleanup failed: " + delErr.Error())
|
||||
}
|
||||
}
|
||||
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist agent")
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, templateAgentResponse(row))
|
||||
}
|
||||
|
||||
// startTemplateAgentAsync starts the template agent in AM off the request path
|
||||
// and records the outcome. The deploy handler returns the Pending agent at once
|
||||
// so it never blocks ~30s on AM (which exceeded the Azure gateway timeout → 504
|
||||
// + rollback). Uses context.Background() because the request context is gone
|
||||
// once the handler returned; the AM HTTP call is still bounded by amTemplateDo's
|
||||
// own client timeout. The client observes Pending -> running (or -> failed) by
|
||||
// polling the agent list, which refreshes live status from AM.
|
||||
func startTemplateAgentAsync(deploymentID string, modelTokenID int, args amStartArgs) {
|
||||
go func() {
|
||||
result, err := amStartTemplateAgent(context.Background(), args)
|
||||
if model.DB == nil {
|
||||
return
|
||||
}
|
||||
now := agentNow()
|
||||
nowMs := time.Now().UnixMilli()
|
||||
if err != nil {
|
||||
common.SysLog("startTemplateAgentAsync: AM start failed for " + deploymentID + ": " + err.Error())
|
||||
reason := err.Error()
|
||||
if len(reason) > 480 {
|
||||
reason = strings.ToValidUTF8(reason[:480], "")
|
||||
}
|
||||
// Only flip Pending->failed. If the user stopped/deleted/cancelled during
|
||||
// the AM start window the status is no longer Pending (or the row is gone),
|
||||
// and we must NOT clobber that terminal intent. The status guard makes the
|
||||
// transition own-or-nothing; LOWER() because the row is stored as "Pending".
|
||||
res := model.DB.Model(&model.AgentDeployment{}).
|
||||
Where("deployment_id = ? AND LOWER(status) = ?", deploymentID, "pending").
|
||||
Updates(map[string]any{
|
||||
"status": "failed",
|
||||
"failure_reason": reason,
|
||||
"updated_at_text": now,
|
||||
"updated_at_ms": nowMs,
|
||||
})
|
||||
// Revoke the minted key ONLY when we owned the Pending->failed transition
|
||||
// (the agent never started). If the user already acted, leave the token to
|
||||
// that path: delete revokes it, stop intentionally retains it. Avoids both
|
||||
// double-revoke and revoking a key the stop path means to keep.
|
||||
if res.Error == nil && res.RowsAffected > 0 {
|
||||
revokeAgentModelToken(modelTokenID)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Success: flip Pending->running and fill in the runtime. Same status guard —
|
||||
// if the user stopped/deleted/cancelled while AM was starting, this updates 0
|
||||
// rows and we must clean up the now-orphaned runtime instead of resurrecting it.
|
||||
res := model.DB.Model(&model.AgentDeployment{}).
|
||||
Where("deployment_id = ? AND LOWER(status) = ?", deploymentID, "pending").
|
||||
Updates(map[string]any{
|
||||
"subdomain": result.Subdomain,
|
||||
"runtime_deployment_id": result.RuntimeID,
|
||||
"status": firstNonEmpty(result.Status, "running"),
|
||||
"updated_at_text": now,
|
||||
"updated_at_ms": nowMs,
|
||||
})
|
||||
if res.Error != nil {
|
||||
common.SysLog("startTemplateAgentAsync: persist result failed for " + deploymentID + ": " + res.Error.Error())
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// No longer Pending: the user stopped/deleted/cancelled while AM was still
|
||||
// starting. Re-read to log which case, then delete the orphan runtime AM
|
||||
// just started — the action is identical either way (clean up, never
|
||||
// resurrect), so the record is never flipped back to running. Token handling
|
||||
// is left to the user's stop/delete path (delete revoked it; stop keeps it).
|
||||
var cur model.AgentDeployment
|
||||
if model.DB.Where("deployment_id = ?", deploymentID).First(&cur).Error != nil {
|
||||
common.SysLog("startTemplateAgentAsync: " + deploymentID + " deleted during AM start; cleaning orphan runtime")
|
||||
} else {
|
||||
common.SysLog("startTemplateAgentAsync: " + deploymentID + " no longer Pending (status=" + cur.Status + ") during AM start; cleaning orphan runtime, preserving user state")
|
||||
}
|
||||
if strings.TrimSpace(result.RuntimeID) != "" {
|
||||
if delErr := amDeleteTemplateAgent(context.Background(), result.RuntimeID); delErr != nil {
|
||||
common.SysLog("startTemplateAgentAsync orphan cleanup failed for " + deploymentID + ": " + delErr.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// findUserTemplateAgent loads a deployed template agent owned by the caller.
|
||||
func findUserTemplateAgent(c *gin.Context) (model.AgentDeployment, bool) {
|
||||
var row model.AgentDeployment
|
||||
@@ -511,41 +350,6 @@ func HeicodeGetAgentStatus(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// HeicodeGetAgentUsage: GET /api/heicode/agents/:deployment_id/usage
|
||||
// Per-agent model-usage rollup (#9): the agent calls HM /v1/* with its minted
|
||||
// token named "agent:<deployment_id>", so its consumption is the sum of consume
|
||||
// logs under that token name. Optional ?start=&end= unix-second window. Returns
|
||||
// raw quota + quota_per_unit (caller converts, same contract as /api/heicode/self).
|
||||
func HeicodeGetAgentUsage(c *gin.Context) {
|
||||
row, ok := findUserTemplateAgent(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
start, _ := strconv.ParseInt(strings.TrimSpace(c.Query("start")), 10, 64)
|
||||
end, _ := strconv.ParseInt(strings.TrimSpace(c.Query("end")), 10, 64)
|
||||
userID := c.GetInt("id")
|
||||
usage, err := model.SumAgentUsage(userID, "agent:"+row.DeploymentID, start, end)
|
||||
if err != nil {
|
||||
agentError(c, "DEPLOYMENT_CONFLICT", "failed to aggregate agent usage")
|
||||
return
|
||||
}
|
||||
// #9: budget_remaining — 用户钱包剩余额度。Agent 模型调用经隐藏 token 计费到
|
||||
// user.Quota(见 #30),故"预算剩余"= 用户剩余额度。-1 表示读取失败(不阻断用量展示)。
|
||||
budgetRemaining := int64(-1)
|
||||
if u, uErr := model.GetUserById(userID, false); uErr == nil && u != nil {
|
||||
budgetRemaining = int64(u.Quota)
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"agent_id": row.DeploymentID,
|
||||
"quota": usage.Quota,
|
||||
"prompt_tokens": usage.PromptTokens,
|
||||
"completion_tokens": usage.CompletionTokens,
|
||||
"call_count": usage.CallCount,
|
||||
"quota_per_unit": common.QuotaPerUnit,
|
||||
"budget_remaining": budgetRemaining,
|
||||
})
|
||||
}
|
||||
|
||||
// HeicodeStopAgent: POST /api/heicode/agents/:deployment_id/stop
|
||||
func HeicodeStopAgent(c *gin.Context) {
|
||||
row, ok := findUserTemplateAgent(c)
|
||||
|
||||
@@ -264,13 +264,3 @@ func TestTemplateAgentResponse(t *testing.T) {
|
||||
require.Equal(t, []int{1, 2}, resp["binding_ids"])
|
||||
require.Equal(t, "running", resp["status"])
|
||||
}
|
||||
|
||||
// #31: agent deployment requires an explicitly configured CRYPTO_SECRET so the
|
||||
// per-agent access_token is sealed with a key that survives a container restart.
|
||||
// common.CryptoSecret is never literally "" (it defaults to uuid/SessionSecret),
|
||||
// so the gate must key off the CRYPTO_SECRET env value, not the runtime var.
|
||||
func TestAgentTokenSealKeyConfigured(t *testing.T) {
|
||||
require.False(t, agentTokenSealKeyConfigured(""))
|
||||
require.False(t, agentTokenSealKeyConfigured(" "))
|
||||
require.True(t, agentTokenSealKeyConfigured("a-real-secret"))
|
||||
}
|
||||
|
||||
@@ -1,296 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// AWS resource discovery (#5). Pure-Go AWS Signature V4 (no AWS SDK), mirroring
|
||||
// the REST approach of azure_resource_discovery.go. Uses the Resource Groups
|
||||
// Tagging API GetResources (AWS JSON 1.1), then normalizes ARNs to discoveredCloudResource.
|
||||
//
|
||||
// ⚠️ 覆盖范围:GetResources 仅返回**已打标签或曾打标签**的资源,**不覆盖完全未打标签的
|
||||
// EC2/RDS/S3 等**——本函数(discoverAWSTagged)是「**tagged resources only**」的一半。
|
||||
// 全量发现由 discoverAWS(aws_resource_discovery_services.go,#62)把本结果与服务级
|
||||
// List/Describe(DescribeInstances / DescribeDBInstances / ListBuckets / ListFunctions)
|
||||
// 按 ARN 合并去重补齐。
|
||||
//
|
||||
// Creds (from cloud_account metadata/secret): access_key_id, secret_access_key,
|
||||
// region, optional session_token.
|
||||
|
||||
type awsCloudDiscoveryProvider struct{}
|
||||
|
||||
func (awsCloudDiscoveryProvider) name() string { return "aws" }
|
||||
|
||||
type awsDiscoveryCredentials struct {
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
SessionToken string
|
||||
Region string
|
||||
}
|
||||
|
||||
func (c awsDiscoveryCredentials) validate() error {
|
||||
if strings.TrimSpace(c.AccessKeyID) == "" {
|
||||
return errors.New("AWS access_key_id required")
|
||||
}
|
||||
if strings.TrimSpace(c.SecretAccessKey) == "" {
|
||||
return errors.New("AWS secret_access_key required")
|
||||
}
|
||||
if strings.TrimSpace(c.Region) == "" {
|
||||
return errors.New("AWS region required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (awsCloudDiscoveryProvider) discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error) {
|
||||
// region may live on the binding's external_id when not in creds.
|
||||
if mapString(creds, "region") == "" && strings.TrimSpace(account.ExternalId) != "" {
|
||||
creds["region"] = account.ExternalId
|
||||
}
|
||||
return discoverAWS(creds, time.Now(), &http.Client{Timeout: 20 * time.Second})
|
||||
}
|
||||
|
||||
// hmacSHA256 / sha256Hex — SigV4 primitives.
|
||||
func hmacSHA256(key, data []byte) []byte {
|
||||
h := hmac.New(sha256.New, key)
|
||||
h.Write(data)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func sha256Hex(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// awsSigV4Authorization computes the SigV4 Authorization header value + x-amz-date
|
||||
// for the given request. signHeaders are the headers (besides host/x-amz-date) to
|
||||
// include in the signature (e.g. content-type, x-amz-target). Pure & deterministic
|
||||
// given t — verified against AWS's official "get-vanilla" test vector.
|
||||
func awsSigV4Authorization(method, rawURL string, signHeaders map[string]string, payload []byte,
|
||||
cred awsDiscoveryCredentials, service string, t time.Time) (authorization, amzDate string, err error) {
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
amzDate = t.UTC().Format("20060102T150405Z")
|
||||
dateStamp := t.UTC().Format("20060102")
|
||||
|
||||
// Canonical headers must include host + x-amz-date. Lowercase names, trim values, sort.
|
||||
headers := map[string]string{}
|
||||
for k, v := range signHeaders {
|
||||
headers[strings.ToLower(strings.TrimSpace(k))] = strings.TrimSpace(v)
|
||||
}
|
||||
headers["host"] = u.Host
|
||||
headers["x-amz-date"] = amzDate
|
||||
if strings.TrimSpace(cred.SessionToken) != "" {
|
||||
headers["x-amz-security-token"] = strings.TrimSpace(cred.SessionToken)
|
||||
}
|
||||
names := make([]string, 0, len(headers))
|
||||
for k := range headers {
|
||||
names = append(names, k)
|
||||
}
|
||||
sort.Strings(names)
|
||||
var canonicalHeaders strings.Builder
|
||||
for _, n := range names {
|
||||
canonicalHeaders.WriteString(n + ":" + headers[n] + "\n")
|
||||
}
|
||||
signedHeaders := strings.Join(names, ";")
|
||||
|
||||
canonicalURI := u.EscapedPath()
|
||||
if canonicalURI == "" {
|
||||
canonicalURI = "/"
|
||||
}
|
||||
// Canonical query string: sort by key, RFC3986-encoded.
|
||||
canonicalQuery := canonicalizeQuery(u.Query())
|
||||
|
||||
payloadHash := sha256Hex(payload)
|
||||
canonicalRequest := method + "\n" + canonicalURI + "\n" + canonicalQuery + "\n" +
|
||||
canonicalHeaders.String() + "\n" + signedHeaders + "\n" + payloadHash
|
||||
|
||||
credentialScope := dateStamp + "/" + cred.Region + "/" + service + "/aws4_request"
|
||||
stringToSign := "AWS4-HMAC-SHA256\n" + amzDate + "\n" + credentialScope + "\n" + sha256Hex([]byte(canonicalRequest))
|
||||
|
||||
kDate := hmacSHA256([]byte("AWS4"+cred.SecretAccessKey), []byte(dateStamp))
|
||||
kRegion := hmacSHA256(kDate, []byte(cred.Region))
|
||||
kService := hmacSHA256(kRegion, []byte(service))
|
||||
kSigning := hmacSHA256(kService, []byte("aws4_request"))
|
||||
signature := hex.EncodeToString(hmacSHA256(kSigning, []byte(stringToSign)))
|
||||
|
||||
authorization = fmt.Sprintf("AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s",
|
||||
cred.AccessKeyID, credentialScope, signedHeaders, signature)
|
||||
return authorization, amzDate, nil
|
||||
}
|
||||
|
||||
func canonicalizeQuery(values url.Values) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
keys := make([]string, 0, len(values))
|
||||
for k := range values {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
vs := append([]string(nil), values[k]...)
|
||||
sort.Strings(vs)
|
||||
for _, v := range vs {
|
||||
parts = append(parts, awsURIEncode(k, true)+"="+awsURIEncode(v, true))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "&")
|
||||
}
|
||||
|
||||
// awsURIEncode is RFC3986 percent-encoding per SigV4 rules.
|
||||
func awsURIEncode(s string, encodeSlash bool) string {
|
||||
var b strings.Builder
|
||||
for _, c := range []byte(s) {
|
||||
switch {
|
||||
case (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
|
||||
c == '-' || c == '_' || c == '.' || c == '~':
|
||||
b.WriteByte(c)
|
||||
case c == '/' && !encodeSlash:
|
||||
b.WriteByte(c)
|
||||
default:
|
||||
b.WriteString(fmt.Sprintf("%%%02X", c))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// awsResourceTagMapping is one entry of the Resource Groups Tagging API response.
|
||||
type awsResourceTagMapping struct {
|
||||
ResourceARN string `json:"ResourceARN"`
|
||||
Tags []struct {
|
||||
Key string `json:"Key"`
|
||||
Value string `json:"Value"`
|
||||
} `json:"Tags"`
|
||||
}
|
||||
|
||||
type awsGetResourcesResponse struct {
|
||||
ResourceTagMappingList []awsResourceTagMapping `json:"ResourceTagMappingList"`
|
||||
PaginationToken string `json:"PaginationToken"`
|
||||
}
|
||||
|
||||
// discoverAWSTagged enumerates the account's tagged resources via the Resource Groups
|
||||
// Tagging API (paginated). Real implementation (no mock). Coverage is "tagged only"
|
||||
// (see file header); discoverAWS (aws_resource_discovery_services.go) merges this with
|
||||
// service-level List/Describe for full-account coverage (#62).
|
||||
func discoverAWSTagged(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
|
||||
endpoint := fmt.Sprintf("https://tagging.%s.amazonaws.com/", cred.Region)
|
||||
const target = "ResourceGroupsTaggingAPI_20170126.GetResources"
|
||||
|
||||
out := []discoveredCloudResource{}
|
||||
paginationToken := ""
|
||||
for page := 0; page < 50; page++ {
|
||||
bodyMap := map[string]any{"ResourcesPerPage": 100}
|
||||
if paginationToken != "" {
|
||||
bodyMap["PaginationToken"] = paginationToken
|
||||
}
|
||||
body, err := common.Marshal(bodyMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
signHeaders := map[string]string{
|
||||
"content-type": "application/x-amz-json-1.1",
|
||||
"x-amz-target": target,
|
||||
}
|
||||
auth, amzDate, err := awsSigV4Authorization(http.MethodPost, endpoint, signHeaders, body, cred, "tagging", now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
|
||||
req.Header.Set("X-Amz-Target", target)
|
||||
req.Header.Set("X-Amz-Date", amzDate)
|
||||
req.Header.Set("Authorization", auth)
|
||||
if strings.TrimSpace(cred.SessionToken) != "" {
|
||||
req.Header.Set("X-Amz-Security-Token", cred.SessionToken)
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AWS GetResources request failed: %w", err)
|
||||
}
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("AWS GetResources failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var payload awsGetResourcesResponse
|
||||
if err := common.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, m := range payload.ResourceTagMappingList {
|
||||
if d, ok := awsARNToResource(m.ResourceARN); ok {
|
||||
tags := map[string]any{}
|
||||
for _, t := range m.Tags {
|
||||
tags[t.Key] = t.Value
|
||||
}
|
||||
d.Metadata = map[string]any{"region": cred.Region, "tags": tags}
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
paginationToken = strings.TrimSpace(payload.PaginationToken)
|
||||
if paginationToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// awsARNToResource parses arn:partition:service:region:account:resource into a
|
||||
// normalized resource. NativeType is CloudFormation-style (AWS::<Service>::<Type>)
|
||||
// so classifyCloudResourceType maps it to vm/database/blob consistently.
|
||||
func awsARNToResource(arn string) (discoveredCloudResource, bool) {
|
||||
arn = strings.TrimSpace(arn)
|
||||
if !strings.HasPrefix(arn, "arn:") {
|
||||
return discoveredCloudResource{}, false
|
||||
}
|
||||
parts := strings.SplitN(arn, ":", 6)
|
||||
if len(parts) < 6 {
|
||||
return discoveredCloudResource{}, false
|
||||
}
|
||||
service := parts[2]
|
||||
region := parts[3]
|
||||
resource := parts[5]
|
||||
// resource may be "type/id", "type:id", or just "id" (e.g. s3 bucket).
|
||||
resType, resName := "", resource
|
||||
if i := strings.IndexAny(resource, "/:"); i >= 0 {
|
||||
resType = resource[:i]
|
||||
resName = resource[i+1:]
|
||||
}
|
||||
native := "AWS::" + strings.ToUpper(service)
|
||||
if resType != "" {
|
||||
native += "::" + resType
|
||||
} else if strings.EqualFold(service, "s3") {
|
||||
native += "::Bucket"
|
||||
}
|
||||
name := resName
|
||||
if name == "" {
|
||||
name = arn
|
||||
}
|
||||
return discoveredCloudResource{
|
||||
ExternalId: arn,
|
||||
Name: name,
|
||||
NativeType: native,
|
||||
Location: region,
|
||||
BindingScope: "aws:" + arn,
|
||||
}, true
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
)
|
||||
|
||||
// Service-level AWS discovery (#62): augments the tagged-only GetResources pass
|
||||
// (aws_resource_discovery.go) with direct List/Describe so **untagged** EC2/RDS/S3/Lambda
|
||||
// resources are found too — closing the "tagged only ≠ full account" gap (#5 follow-up).
|
||||
//
|
||||
// 设计:
|
||||
// - discoverAWS 编排:先 tagged(最全的标签信息),再各服务级 List/Describe,按 ARN 合并去重
|
||||
// (tagged 优先保留,信息更丰富)。
|
||||
// - **失败降级**:任一服务调用失败(如缺该服务读权限 / AccessDenied)只记日志并跳过,绝不让
|
||||
// 整次发现失败;仅当「tagged 报错且无任何服务返回结果」时,才把 tagged 的原始错误抛出,
|
||||
// 好让账号凭据/区域问题对用户可见。
|
||||
// - 复用 awsSigV4Authorization(已对 AWS get-vanilla 向量验证)签 GET;无 AWS SDK 依赖。
|
||||
// - 区域:EC2/RDS/Lambda 用账号配置的 region;S3 ListBuckets 是全局接口(以 us-east-1 签名)。
|
||||
// 跨 region 全量扫描不在本次范围(EC2/RDS 仅扫配置 region),由 #62 备注另行跟踪。
|
||||
// - 解析逻辑(parseAWS* )为纯函数,便于用真实响应样本单测。
|
||||
|
||||
// discoverAWS enumerates the account's resources by merging the tagged-resources pass
|
||||
// with service-level List/Describe (#62). Replaces the old tagged-only discoverAWS.
|
||||
func discoverAWS(creds map[string]any, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
|
||||
cred := awsDiscoveryCredentials{
|
||||
AccessKeyID: mapString(creds, "access_key_id"),
|
||||
SecretAccessKey: mapString(creds, "secret_access_key"),
|
||||
SessionToken: mapString(creds, "session_token"),
|
||||
Region: mapString(creds, "region"),
|
||||
}
|
||||
if err := cred.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var lists [][]discoveredCloudResource
|
||||
tagged, taggedErr := discoverAWSTagged(cred, now, httpClient)
|
||||
if taggedErr != nil {
|
||||
common.SysLog("discoverAWS tagged: " + taggedErr.Error())
|
||||
} else {
|
||||
lists = append(lists, tagged) // tagged 先入,合并时优先保留
|
||||
}
|
||||
|
||||
services := []struct {
|
||||
name string
|
||||
fn func(awsDiscoveryCredentials, time.Time, *http.Client) ([]discoveredCloudResource, error)
|
||||
}{
|
||||
{"ec2", discoverAWSEC2},
|
||||
{"rds", discoverAWSRDS},
|
||||
{"s3", discoverAWSS3},
|
||||
{"lambda", discoverAWSLambda},
|
||||
}
|
||||
for _, svc := range services {
|
||||
list, err := svc.fn(cred, now, httpClient)
|
||||
if err != nil {
|
||||
common.SysLog("discoverAWS " + svc.name + ": " + err.Error())
|
||||
continue
|
||||
}
|
||||
lists = append(lists, list)
|
||||
}
|
||||
|
||||
merged := mergeDedupeByExternalID(lists...)
|
||||
// 全部为空且 tagged 当初报错 → 抛出原始错误,让凭据/区域问题可见(纯权限缺失则降级返回已得结果)。
|
||||
if len(merged) == 0 && taggedErr != nil {
|
||||
return nil, taggedErr
|
||||
}
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// mergeDedupeByExternalID 按 ExternalId(ARN)合并多组发现结果,先到先得(靠前的列表优先保留),
|
||||
// 保持稳定顺序。纯函数,便于单测。
|
||||
func mergeDedupeByExternalID(lists ...[]discoveredCloudResource) []discoveredCloudResource {
|
||||
seen := map[string]bool{}
|
||||
out := make([]discoveredCloudResource, 0)
|
||||
for _, list := range lists {
|
||||
for _, d := range list {
|
||||
id := strings.TrimSpace(d.ExternalId)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// awsSignedGet 发起一次 SigV4 签名的 GET(空 body),返回响应体。非 2xx 视为错误。
|
||||
func awsSignedGet(cred awsDiscoveryCredentials, rawURL, service string, now time.Time, httpClient *http.Client) ([]byte, error) {
|
||||
auth, amzDate, err := awsSigV4Authorization(http.MethodGet, rawURL, map[string]string{}, nil, cred, service, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("X-Amz-Date", amzDate)
|
||||
req.Header.Set("Authorization", auth)
|
||||
if strings.TrimSpace(cred.SessionToken) != "" {
|
||||
req.Header.Set("X-Amz-Security-Token", cred.SessionToken)
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return body, fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// ---- EC2 DescribeInstances (Query 协议, XML) ----
|
||||
|
||||
type ec2DescribeInstancesResponse struct {
|
||||
XMLName xml.Name `xml:"DescribeInstancesResponse"`
|
||||
Reservations []struct {
|
||||
OwnerID string `xml:"ownerId"`
|
||||
Instances []struct {
|
||||
InstanceID string `xml:"instanceId"`
|
||||
InstanceType string `xml:"instanceType"`
|
||||
State struct {
|
||||
Name string `xml:"name"`
|
||||
} `xml:"instanceState"`
|
||||
Placement struct {
|
||||
AvailabilityZone string `xml:"availabilityZone"`
|
||||
} `xml:"placement"`
|
||||
Tags []struct {
|
||||
Key string `xml:"key"`
|
||||
Value string `xml:"value"`
|
||||
} `xml:"tagSet>item"`
|
||||
} `xml:"instancesSet>item"`
|
||||
} `xml:"reservationSet>item"`
|
||||
NextToken string `xml:"nextToken"`
|
||||
}
|
||||
|
||||
// parseEC2Instances 解析 DescribeInstances XML → 归一化资源 + nextToken。纯函数。
|
||||
func parseEC2Instances(body []byte, region string) ([]discoveredCloudResource, string, error) {
|
||||
var r ec2DescribeInstancesResponse
|
||||
if err := xml.Unmarshal(body, &r); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
out := []discoveredCloudResource{}
|
||||
for _, res := range r.Reservations {
|
||||
for _, inst := range res.Instances {
|
||||
if strings.TrimSpace(inst.InstanceID) == "" {
|
||||
continue
|
||||
}
|
||||
arn := fmt.Sprintf("arn:aws:ec2:%s:%s:instance/%s", region, res.OwnerID, inst.InstanceID)
|
||||
tags := map[string]any{}
|
||||
name := inst.InstanceID
|
||||
for _, t := range inst.Tags {
|
||||
tags[t.Key] = t.Value
|
||||
if strings.EqualFold(t.Key, "Name") && strings.TrimSpace(t.Value) != "" {
|
||||
name = t.Value
|
||||
}
|
||||
}
|
||||
out = append(out, discoveredCloudResource{
|
||||
ExternalId: arn,
|
||||
Name: name,
|
||||
NativeType: "AWS::EC2::Instance",
|
||||
Location: region,
|
||||
BindingScope: "aws:" + arn,
|
||||
Metadata: map[string]any{
|
||||
"region": region,
|
||||
"instance_type": inst.InstanceType,
|
||||
"state": inst.State.Name,
|
||||
"tags": tags,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
return out, strings.TrimSpace(r.NextToken), nil
|
||||
}
|
||||
|
||||
func discoverAWSEC2(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
|
||||
out := []discoveredCloudResource{}
|
||||
nextToken := ""
|
||||
for page := 0; page < 50; page++ {
|
||||
q := url.Values{}
|
||||
q.Set("Action", "DescribeInstances")
|
||||
q.Set("Version", "2016-11-15")
|
||||
q.Set("MaxResults", "100")
|
||||
if nextToken != "" {
|
||||
q.Set("NextToken", nextToken)
|
||||
}
|
||||
rawURL := fmt.Sprintf("https://ec2.%s.amazonaws.com/?%s", cred.Region, q.Encode())
|
||||
body, err := awsSignedGet(cred, rawURL, "ec2", now, httpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page1, token, err := parseEC2Instances(body, cred.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, page1...)
|
||||
nextToken = token
|
||||
if nextToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- RDS DescribeDBInstances (Query 协议, XML) ----
|
||||
|
||||
type rdsDescribeDBInstancesResponse struct {
|
||||
XMLName xml.Name `xml:"DescribeDBInstancesResponse"`
|
||||
Result struct {
|
||||
Marker string `xml:"Marker"`
|
||||
DBInstances []struct {
|
||||
Arn string `xml:"DBInstanceArn"`
|
||||
Identifier string `xml:"DBInstanceIdentifier"`
|
||||
Engine string `xml:"Engine"`
|
||||
Status string `xml:"DBInstanceStatus"`
|
||||
AvailabilityZone string `xml:"AvailabilityZone"`
|
||||
} `xml:"DBInstances>DBInstance"`
|
||||
} `xml:"DescribeDBInstancesResult"`
|
||||
}
|
||||
|
||||
// parseRDSInstances 解析 DescribeDBInstances XML → 归一化资源 + marker。纯函数。
|
||||
func parseRDSInstances(body []byte, region string) ([]discoveredCloudResource, string, error) {
|
||||
var r rdsDescribeDBInstancesResponse
|
||||
if err := xml.Unmarshal(body, &r); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
out := []discoveredCloudResource{}
|
||||
for _, db := range r.Result.DBInstances {
|
||||
arn := strings.TrimSpace(db.Arn)
|
||||
if arn == "" {
|
||||
continue // 无 ARN 不入(稳定外部 id 缺失)
|
||||
}
|
||||
name := strings.TrimSpace(db.Identifier)
|
||||
if name == "" {
|
||||
name = arn
|
||||
}
|
||||
out = append(out, discoveredCloudResource{
|
||||
ExternalId: arn,
|
||||
Name: name,
|
||||
NativeType: "AWS::RDS::DBInstance",
|
||||
Location: region,
|
||||
BindingScope: "aws:" + arn,
|
||||
Metadata: map[string]any{
|
||||
"region": region,
|
||||
"engine": db.Engine,
|
||||
"status": db.Status,
|
||||
"az": db.AvailabilityZone,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out, strings.TrimSpace(r.Result.Marker), nil
|
||||
}
|
||||
|
||||
func discoverAWSRDS(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
|
||||
out := []discoveredCloudResource{}
|
||||
marker := ""
|
||||
for page := 0; page < 50; page++ {
|
||||
q := url.Values{}
|
||||
q.Set("Action", "DescribeDBInstances")
|
||||
q.Set("Version", "2014-10-31")
|
||||
q.Set("MaxRecords", "100")
|
||||
if marker != "" {
|
||||
q.Set("Marker", marker)
|
||||
}
|
||||
rawURL := fmt.Sprintf("https://rds.%s.amazonaws.com/?%s", cred.Region, q.Encode())
|
||||
body, err := awsSignedGet(cred, rawURL, "rds", now, httpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page1, next, err := parseRDSInstances(body, cred.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, page1...)
|
||||
marker = next
|
||||
if marker == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- S3 ListBuckets (REST-XML, 全局) ----
|
||||
|
||||
type s3ListAllMyBucketsResult struct {
|
||||
XMLName xml.Name `xml:"ListAllMyBucketsResult"`
|
||||
Buckets []struct {
|
||||
Name string `xml:"Name"`
|
||||
} `xml:"Buckets>Bucket"`
|
||||
}
|
||||
|
||||
// parseS3Buckets 解析 ListBuckets XML → 归一化资源。纯函数。S3 bucket ARN 无 region/account 段。
|
||||
func parseS3Buckets(body []byte) ([]discoveredCloudResource, error) {
|
||||
var r s3ListAllMyBucketsResult
|
||||
if err := xml.Unmarshal(body, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []discoveredCloudResource{}
|
||||
for _, b := range r.Buckets {
|
||||
name := strings.TrimSpace(b.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
arn := "arn:aws:s3:::" + name
|
||||
out = append(out, discoveredCloudResource{
|
||||
ExternalId: arn,
|
||||
Name: name,
|
||||
NativeType: "AWS::S3::Bucket",
|
||||
Location: "", // 全局列举不含 region;按需 GetBucketLocation(未做,避免逐桶请求)
|
||||
BindingScope: "aws:" + arn,
|
||||
Metadata: map[string]any{"bucket": name},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func discoverAWSS3(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
|
||||
// ListBuckets 是全局接口,固定以 us-east-1 / service=s3 签名。
|
||||
s3Cred := cred
|
||||
s3Cred.Region = "us-east-1"
|
||||
body, err := awsSignedGet(s3Cred, "https://s3.amazonaws.com/", "s3", now, httpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseS3Buckets(body)
|
||||
}
|
||||
|
||||
// ---- Lambda ListFunctions (REST-JSON) ----
|
||||
|
||||
type lambdaListFunctionsResponse struct {
|
||||
Functions []struct {
|
||||
FunctionArn string `json:"FunctionArn"`
|
||||
FunctionName string `json:"FunctionName"`
|
||||
Runtime string `json:"Runtime"`
|
||||
} `json:"Functions"`
|
||||
NextMarker string `json:"NextMarker"`
|
||||
}
|
||||
|
||||
// parseLambdaFunctions 解析 ListFunctions JSON → 归一化资源 + nextMarker。纯函数。
|
||||
func parseLambdaFunctions(body []byte, region string) ([]discoveredCloudResource, string, error) {
|
||||
var r lambdaListFunctionsResponse
|
||||
if err := common.Unmarshal(body, &r); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
out := []discoveredCloudResource{}
|
||||
for _, fn := range r.Functions {
|
||||
arn := strings.TrimSpace(fn.FunctionArn)
|
||||
if arn == "" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(fn.FunctionName)
|
||||
if name == "" {
|
||||
name = arn
|
||||
}
|
||||
out = append(out, discoveredCloudResource{
|
||||
ExternalId: arn,
|
||||
Name: name,
|
||||
NativeType: "AWS::Lambda::Function",
|
||||
Location: region,
|
||||
BindingScope: "aws:" + arn,
|
||||
Metadata: map[string]any{"region": region, "runtime": fn.Runtime},
|
||||
})
|
||||
}
|
||||
return out, strings.TrimSpace(r.NextMarker), nil
|
||||
}
|
||||
|
||||
func discoverAWSLambda(cred awsDiscoveryCredentials, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
|
||||
out := []discoveredCloudResource{}
|
||||
marker := ""
|
||||
for page := 0; page < 50; page++ {
|
||||
q := url.Values{}
|
||||
q.Set("MaxItems", "50")
|
||||
if marker != "" {
|
||||
q.Set("Marker", marker)
|
||||
}
|
||||
rawURL := fmt.Sprintf("https://lambda.%s.amazonaws.com/2015-03-31/functions/?%s", cred.Region, q.Encode())
|
||||
body, err := awsSignedGet(cred, rawURL, "lambda", now, httpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page1, next, err := parseLambdaFunctions(body, cred.Region)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, page1...)
|
||||
marker = next
|
||||
if marker == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Cloud resource discovery — provider-agnostic layer (#5).
|
||||
//
|
||||
// 历史上只有 Azure 发现(controller/azure_resource_discovery.go,硬要求 provider==azure)。
|
||||
// 本文件抽象出统一的 provider 接口,让 AWS / GCP 走同一条「发现 → 归一化 → 落库」路径,
|
||||
// 并对 vm / database / blob 做跨云一致的分类(DoD #3)。Azure 适配器复用既有 ARM 客户端,
|
||||
// 故三家行为一致;具体云的签名/鉴权在 aws_resource_discovery.go / gcp_resource_discovery.go。
|
||||
|
||||
// discoveredCloudResource 是各 provider 适配器输出的**归一化**资源视图。upsert 时由统一
|
||||
// 路径补 source_account_id 与 classified_type,并写入 ResourceBinding(resource_type=cloud_resource)。
|
||||
type discoveredCloudResource struct {
|
||||
ExternalId string // ARN / Azure resource id / GCP asset name —— 跨 provider 的稳定外部 id
|
||||
Name string // 展示名
|
||||
NativeType string // provider 原生类型字符串(如 Microsoft.Compute/virtualMachines、AWS::EC2::Instance、compute.googleapis.com/Instance)
|
||||
Location string // region / location
|
||||
BindingScope string // 可选;为空时默认 "<provider>:<external_id>"
|
||||
Metadata map[string]any // provider 特定附加字段
|
||||
}
|
||||
|
||||
// cloudDiscoveryProvider 是「连接云账号 → 列资源」的统一抽象(DoD #3)。
|
||||
type cloudDiscoveryProvider interface {
|
||||
name() string
|
||||
// discover 用 creds(已合并 metadata + secret JSON)列出 account 下的资源。
|
||||
discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error)
|
||||
}
|
||||
|
||||
func cloudDiscoveryProviderByName(provider string) (cloudDiscoveryProvider, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(provider)) {
|
||||
case "azure":
|
||||
return azureCloudDiscoveryProvider{}, true
|
||||
case "aws", "amazon":
|
||||
return awsCloudDiscoveryProvider{}, true
|
||||
case "gcp", "google", "gce":
|
||||
return gcpCloudDiscoveryProvider{}, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// classifyCloudResourceType 把 provider 原生类型映射到跨云一致的逻辑类别(DoD #3):
|
||||
// vm / database / blob / cloud_resource(兜底)。基于不区分大小写的子串匹配,覆盖三家常见服务。
|
||||
func classifyCloudResourceType(nativeType string) string {
|
||||
t := strings.ToLower(nativeType)
|
||||
containsAny := func(s string, subs ...string) bool {
|
||||
for _, sub := range subs {
|
||||
if strings.Contains(s, sub) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
// 数据库(放在 vm 之前:避免 "sqlvirtual..." 之类被误判为 vm;数据库特征更强)
|
||||
case containsAny(t, "sql", "postgres", "mysql", "mariadb", "rds", "database", "cosmos",
|
||||
"dynamodb", "spanner", "cloudsql", "bigtable", "redis", "documentdb", "memcache"):
|
||||
return "database"
|
||||
case containsAny(t, "virtualmachine", "ec2::instance", "ec2/instance", "compute/instance",
|
||||
"compute.googleapis.com/instance", "virtualmachinescaleset", "instance"):
|
||||
return "vm"
|
||||
case containsAny(t, "storageaccount", "::s3::", "s3:::", "/buckets/", "storage/bucket",
|
||||
"storage.googleapis.com/bucket", "blob", "bucket"):
|
||||
return "blob"
|
||||
default:
|
||||
return "cloud_resource"
|
||||
}
|
||||
}
|
||||
|
||||
// upsertDiscoveredCloudResources 把归一化资源幂等写入 ResourceBinding(按 user+type+provider+external_id
|
||||
// 去重)。统一所有 provider 的落库格式:resource_type 固定为 cloud_resource(与既有 Azure 行为一致,
|
||||
// 不让发现的资源直接变成可绑定的 vm/db/blob),跨云类别放在 metadata.classified_type。
|
||||
func upsertDiscoveredCloudResources(account model.ResourceBinding, provider string, discovered []discoveredCloudResource) ([]resourceResponse, error) {
|
||||
items := make([]resourceResponse, 0, len(discovered))
|
||||
for _, d := range discovered {
|
||||
if strings.TrimSpace(d.ExternalId) == "" {
|
||||
continue
|
||||
}
|
||||
var resource model.ResourceBinding
|
||||
err := model.DB.Where(
|
||||
"user_id = ? AND resource_type = ? AND provider = ? AND external_id = ?",
|
||||
account.UserId, "cloud_resource", provider, d.ExternalId,
|
||||
).First(&resource).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if err := applyDiscoveredResourceFields(&resource, account, provider, d); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resource.Id == 0 {
|
||||
if err := model.DB.Create(&resource).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := model.DB.Save(&resource).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, resourceToResponse(resource))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// applyDiscoveredResourceFields 把一条归一化发现结果写入 ResourceBinding 字段(DB 无关,可单测)。
|
||||
// 关键安全约束(#5 复审 #1):**SecretRef 恒为空**——发现出的子资源绝不继承云账号 secret_ref,
|
||||
// 否则会把账号级凭据引用扩散到每个 VM/S3/DB 并经 resourceToResponse / grant manifest 下发给 agent。
|
||||
// 发现是只读清单;如需 agent 读取某资源,应另行 grant/审批/最小权限 secret。
|
||||
func applyDiscoveredResourceFields(resource *model.ResourceBinding, account model.ResourceBinding, provider string, d discoveredCloudResource) error {
|
||||
metadata := map[string]any{}
|
||||
for k, v := range d.Metadata {
|
||||
metadata[k] = v
|
||||
}
|
||||
metadata["source_account_id"] = account.Id
|
||||
metadata["classified_type"] = classifyCloudResourceType(d.NativeType)
|
||||
if strings.TrimSpace(d.NativeType) != "" {
|
||||
metadata["native_type"] = d.NativeType
|
||||
}
|
||||
if strings.TrimSpace(d.Location) != "" {
|
||||
metadata["location"] = d.Location
|
||||
}
|
||||
metadataJSON, err := marshalResourceJSON(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
permissionScopeJSON, err := marshalResourceJSON(map[string]any{"actions": []string{provider + ":read"}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
constraintsJSON, err := marshalResourceJSON(unmarshalResourceJSON(account.Constraints))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := strings.TrimSpace(d.Name)
|
||||
if name == "" {
|
||||
name = d.ExternalId
|
||||
}
|
||||
bindingScope := strings.TrimSpace(d.BindingScope)
|
||||
if bindingScope == "" {
|
||||
bindingScope = fmt.Sprintf("%s:%s", provider, d.ExternalId)
|
||||
}
|
||||
resource.UserId = account.UserId
|
||||
resource.TenantId = account.TenantId
|
||||
resource.ProjectId = account.ProjectId
|
||||
resource.BindingScope = bindingScope
|
||||
resource.Name = name
|
||||
resource.ResourceType = "cloud_resource"
|
||||
resource.Provider = provider
|
||||
resource.ExternalId = d.ExternalId
|
||||
resource.SecretRef = "" // 见上:绝不继承账号 secret_ref
|
||||
resource.Metadata = metadataJSON
|
||||
resource.PermissionScope = permissionScopeJSON
|
||||
resource.Constraints = constraintsJSON
|
||||
resource.Status = "active"
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscoverCloudResources: POST /api/resources/:id/discover-cloud — provider-agnostic
|
||||
// discovery dispatcher (#5). Routes by the account's provider to the matching
|
||||
// adapter (azure/aws/gcp), then upserts via the unified path.
|
||||
func DiscoverCloudResources(c *gin.Context) {
|
||||
userId := c.GetInt("id")
|
||||
var account model.ResourceBinding
|
||||
if err := model.DB.Where("id = ? AND user_id = ?", c.Param("id"), userId).First(&account).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.ApiErrorMsg(c, "resource not found")
|
||||
return
|
||||
}
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if account.ResourceType != "cloud_account" || account.Status != "active" {
|
||||
common.ApiErrorMsg(c, "resource must be an active cloud_account")
|
||||
return
|
||||
}
|
||||
impl, ok := cloudDiscoveryProviderByName(account.Provider)
|
||||
if !ok {
|
||||
common.ApiErrorMsg(c, "unsupported cloud provider: "+account.Provider+" (supported: azure, aws, gcp)")
|
||||
return
|
||||
}
|
||||
|
||||
// creds = metadata(非密)合并 secret JSON(密)。secret 优先级在适配器内部按需处理。
|
||||
creds := unmarshalResourceJSON(account.Metadata)
|
||||
if creds == nil {
|
||||
creds = map[string]any{}
|
||||
}
|
||||
if strings.TrimSpace(account.SecretRef) != "" {
|
||||
secretClient, err := newSecretStoreClientFromEnv()
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
secretData, err := secretClient.getJSONSecret(account.SecretRef)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
for k, v := range secretData {
|
||||
creds[k] = v // secret 覆盖同名非密字段
|
||||
}
|
||||
}
|
||||
|
||||
discovered, err := impl.discover(account, creds)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
items, err := upsertDiscoveredCloudResources(account, impl.name(), discovered)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"account": resourceToResponse(account),
|
||||
"provider": impl.name(),
|
||||
"items": items,
|
||||
"discovered": len(items),
|
||||
})
|
||||
}
|
||||
|
||||
// azureCloudDiscoveryProvider 让既有 Azure ARM 发现实现统一接口(复用 azure_resource_discovery.go
|
||||
// 的 client/token/list),保证三家走同一抽象。
|
||||
type azureCloudDiscoveryProvider struct{}
|
||||
|
||||
func (azureCloudDiscoveryProvider) name() string { return "azure" }
|
||||
|
||||
func (azureCloudDiscoveryProvider) discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error) {
|
||||
cred := azureDiscoveryCredentials{
|
||||
SubscriptionID: firstString(creds, nil, account.ExternalId, "subscription_id"),
|
||||
TenantID: firstString(creds, nil, account.TenantId, "tenant_id"),
|
||||
ClientID: mapString(creds, "client_id"),
|
||||
ClientSecret: mapString(creds, "client_secret"),
|
||||
}
|
||||
if err := cred.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client := newAzureResourceDiscoveryClientFromEnv()
|
||||
token, err := client.clientCredentialsToken(cred)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arm, err := client.listSubscriptionResources(cred.SubscriptionID, token)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]discoveredCloudResource, 0, len(arm))
|
||||
for _, r := range arm {
|
||||
out = append(out, discoveredCloudResource{
|
||||
ExternalId: r.ID,
|
||||
Name: r.Name,
|
||||
NativeType: r.Type,
|
||||
Location: r.Location,
|
||||
BindingScope: fmt.Sprintf("azure:%s:%s", cred.SubscriptionID, r.ID),
|
||||
Metadata: map[string]any{
|
||||
"subscription_id": cred.SubscriptionID,
|
||||
"tenant_id": cred.TenantID,
|
||||
"resource_group": azureResourceGroupFromID(r.ID),
|
||||
"tags": r.Tags,
|
||||
},
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/heicode/manager/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// #5: 跨云类别分类 vm/database/blob 一致。
|
||||
func TestClassifyCloudResourceType(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// vm
|
||||
"Microsoft.Compute/virtualMachines": "vm",
|
||||
"AWS::EC2::Instance": "vm",
|
||||
"compute.googleapis.com/Instance": "vm",
|
||||
// database
|
||||
"Microsoft.Sql/servers/databases": "database",
|
||||
"AWS::RDS::DBInstance": "database",
|
||||
"sqladmin.googleapis.com/Instance": "database",
|
||||
"AWS::DynamoDB::Table": "database",
|
||||
// blob / object storage
|
||||
"Microsoft.Storage/storageAccounts": "blob",
|
||||
"AWS::S3::Bucket": "blob",
|
||||
"storage.googleapis.com/Bucket": "blob",
|
||||
// fallback
|
||||
"Microsoft.Network/virtualNetworks": "cloud_resource",
|
||||
"AWS::IAM::Role": "cloud_resource",
|
||||
}
|
||||
for native, want := range cases {
|
||||
require.Equal(t, want, classifyCloudResourceType(native), "classify %s", native)
|
||||
}
|
||||
}
|
||||
|
||||
// #5: ARN 解析 → 归一化 + 正确分类。
|
||||
func TestAWSARNToResource(t *testing.T) {
|
||||
ec2, ok := awsARNToResource("arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "us-east-1", ec2.Location)
|
||||
require.Equal(t, "i-0abc123", ec2.Name)
|
||||
require.Equal(t, "vm", classifyCloudResourceType(ec2.NativeType))
|
||||
|
||||
s3, ok := awsARNToResource("arn:aws:s3:::my-bucket")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "my-bucket", s3.Name)
|
||||
require.Equal(t, "blob", classifyCloudResourceType(s3.NativeType))
|
||||
|
||||
rds, ok := awsARNToResource("arn:aws:rds:eu-west-1:123:db:prod-pg")
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "database", classifyCloudResourceType(rds.NativeType))
|
||||
|
||||
_, ok = awsARNToResource("not-an-arn")
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// #5: SigV4 实现正确性 —— 对齐 AWS 官方 "get-vanilla" 测试向量。
|
||||
func TestAWSSigV4_VanillaVector(t *testing.T) {
|
||||
cred := awsDiscoveryCredentials{
|
||||
AccessKeyID: "AKIDEXAMPLE",
|
||||
SecretAccessKey: "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY",
|
||||
Region: "us-east-1",
|
||||
}
|
||||
tm := time.Date(2015, 8, 30, 12, 36, 0, 0, time.UTC)
|
||||
auth, amzDate, err := awsSigV4Authorization("GET", "https://example.amazonaws.com/", map[string]string{}, []byte(""), cred, "service", tm)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "20150830T123600Z", amzDate)
|
||||
require.Contains(t, auth, "Credential=AKIDEXAMPLE/20150830/us-east-1/service/aws4_request")
|
||||
require.Contains(t, auth, "SignedHeaders=host;x-amz-date")
|
||||
// 官方向量期望签名
|
||||
require.Contains(t, auth, "Signature=5fa00fa31553b73ebf1942676e86291e8372ff2a2260956d9b8aae1d763fbf31")
|
||||
}
|
||||
|
||||
// #5 复审 #3:GCP searchAllResources URL —— scope 的 slash 不得被 %2F 转义。
|
||||
func TestGCPSearchAllResourcesURL(t *testing.T) {
|
||||
u := gcpSearchAllResourcesURL("my-proj", "")
|
||||
require.Equal(t, "https://cloudasset.googleapis.com/v1/projects/my-proj:searchAllResources?pageSize=500", u)
|
||||
require.NotContains(t, u, "%2F", "scope slash 不应被转义")
|
||||
|
||||
u2 := gcpSearchAllResourcesURL("my-proj", "tok en/+")
|
||||
require.Contains(t, u2, "&pageToken=tok+en%2F%2B")
|
||||
}
|
||||
|
||||
// #5 复审 #1:发现出的资源**绝不继承**账号 secret_ref;元数据带 classified_type,不含账号凭据引用。
|
||||
func TestApplyDiscoveredResourceFields_NoSecretInheritance(t *testing.T) {
|
||||
account := model.ResourceBinding{
|
||||
Id: 42, UserId: 7, ResourceType: "cloud_account", Provider: "aws",
|
||||
SecretRef: "azkv://heicode-kv.vault.azure.net/secrets/aws-keys",
|
||||
}
|
||||
d := discoveredCloudResource{
|
||||
ExternalId: "arn:aws:ec2:us-east-1:123:instance/i-1",
|
||||
Name: "i-1", NativeType: "AWS::EC2::Instance", Location: "us-east-1",
|
||||
Metadata: map[string]any{"region": "us-east-1"},
|
||||
}
|
||||
var r model.ResourceBinding
|
||||
require.NoError(t, applyDiscoveredResourceFields(&r, account, "aws", d))
|
||||
|
||||
require.Equal(t, "", r.SecretRef, "发现出的子资源绝不继承账号 secret_ref")
|
||||
require.Equal(t, "cloud_resource", r.ResourceType)
|
||||
require.Equal(t, "aws", r.Provider)
|
||||
require.Equal(t, 7, r.UserId)
|
||||
require.NotContains(t, r.Metadata, "azkv://", "metadata 不得含账号凭据引用")
|
||||
require.Contains(t, r.Metadata, "\"classified_type\":\"vm\"")
|
||||
require.Contains(t, r.PermissionScope, "aws:read")
|
||||
}
|
||||
|
||||
// #5: GCP SA JWT —— RS256 断言可被对应公钥验签,且 claims 正确。
|
||||
func TestBuildGCPAssertion_RS256Roundtrip(t *testing.T) {
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
der, err := x509.MarshalPKCS8PrivateKey(key)
|
||||
require.NoError(t, err)
|
||||
pemStr := string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}))
|
||||
|
||||
cred := gcpDiscoveryCredentials{
|
||||
ClientEmail: "svc@proj.iam.gserviceaccount.com",
|
||||
PrivateKey: pemStr,
|
||||
ProjectID: "proj",
|
||||
}
|
||||
tm := time.Date(2026, 6, 10, 0, 0, 0, 0, time.UTC)
|
||||
jwt, err := buildGCPAssertion(cred, "https://www.googleapis.com/auth/cloud-platform", "https://oauth2.googleapis.com/token", tm)
|
||||
require.NoError(t, err)
|
||||
|
||||
parts := strings.Split(jwt, ".")
|
||||
require.Len(t, parts, 3)
|
||||
|
||||
// 验签:signingInput = header.payload
|
||||
signingInput := parts[0] + "." + parts[1]
|
||||
digest := sha256.Sum256([]byte(signingInput))
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, digest[:], sig), "RS256 签名应可被公钥验证")
|
||||
|
||||
// header alg + claims
|
||||
hdr, err := base64.RawURLEncoding.DecodeString(parts[0])
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(hdr), "RS256")
|
||||
claims, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(claims), "svc@proj.iam.gserviceaccount.com")
|
||||
require.Contains(t, string(claims), "cloud-platform")
|
||||
}
|
||||
|
||||
// #62: EC2 DescribeInstances XML 解析 —— 含 Name 标签 / ownerId / nextToken;ARN 由 region+owner+id 组装。
|
||||
func TestParseEC2Instances(t *testing.T) {
|
||||
body := []byte(`<?xml version="1.0"?>
|
||||
<DescribeInstancesResponse xmlns="http://ec2.amazonaws.com/doc/2016-11-15/">
|
||||
<reservationSet>
|
||||
<item>
|
||||
<ownerId>123456789012</ownerId>
|
||||
<instancesSet>
|
||||
<item>
|
||||
<instanceId>i-0abc123</instanceId>
|
||||
<instanceType>t3.micro</instanceType>
|
||||
<instanceState><name>running</name></instanceState>
|
||||
<placement><availabilityZone>us-east-1a</availabilityZone></placement>
|
||||
<tagSet>
|
||||
<item><key>Name</key><value>web-1</value></item>
|
||||
<item><key>env</key><value>prod</value></item>
|
||||
</tagSet>
|
||||
</item>
|
||||
<item>
|
||||
<instanceId>i-0def456</instanceId>
|
||||
<instanceType>t3.small</instanceType>
|
||||
</item>
|
||||
</instancesSet>
|
||||
</item>
|
||||
</reservationSet>
|
||||
<nextToken>NEXT==</nextToken>
|
||||
</DescribeInstancesResponse>`)
|
||||
out, next, err := parseEC2Instances(body, "us-east-1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "NEXT==", next)
|
||||
require.Len(t, out, 2)
|
||||
require.Equal(t, "arn:aws:ec2:us-east-1:123456789012:instance/i-0abc123", out[0].ExternalId)
|
||||
require.Equal(t, "web-1", out[0].Name) // Name 标签优先
|
||||
require.Equal(t, "AWS::EC2::Instance", out[0].NativeType)
|
||||
require.Equal(t, "vm", classifyCloudResourceType(out[0].NativeType))
|
||||
require.Equal(t, "i-0def456", out[1].Name) // 无 Name 标签回退 instanceId
|
||||
}
|
||||
|
||||
// #62: RDS DescribeDBInstances XML 解析 —— ARN 直接取 DBInstanceArn;无 ARN 跳过;Marker 续页。
|
||||
func TestParseRDSInstances(t *testing.T) {
|
||||
body := []byte(`<DescribeDBInstancesResponse xmlns="http://rds.amazonaws.com/doc/2014-10-31/">
|
||||
<DescribeDBInstancesResult>
|
||||
<Marker>m2</Marker>
|
||||
<DBInstances>
|
||||
<DBInstance>
|
||||
<DBInstanceArn>arn:aws:rds:eu-west-1:123456789012:db:prod-pg</DBInstanceArn>
|
||||
<DBInstanceIdentifier>prod-pg</DBInstanceIdentifier>
|
||||
<Engine>postgres</Engine>
|
||||
<DBInstanceStatus>available</DBInstanceStatus>
|
||||
</DBInstance>
|
||||
<DBInstance>
|
||||
<DBInstanceIdentifier>no-arn</DBInstanceIdentifier>
|
||||
<Engine>mysql</Engine>
|
||||
</DBInstance>
|
||||
</DBInstances>
|
||||
</DescribeDBInstancesResult>
|
||||
</DescribeDBInstancesResponse>`)
|
||||
out, marker, err := parseRDSInstances(body, "eu-west-1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "m2", marker)
|
||||
require.Len(t, out, 1) // 无 ARN 的被跳过
|
||||
require.Equal(t, "arn:aws:rds:eu-west-1:123456789012:db:prod-pg", out[0].ExternalId)
|
||||
require.Equal(t, "prod-pg", out[0].Name)
|
||||
require.Equal(t, "database", classifyCloudResourceType(out[0].NativeType))
|
||||
}
|
||||
|
||||
// #62: S3 ListBuckets XML 解析 —— ARN 为 arn:aws:s3:::<name>,全局无 region。
|
||||
func TestParseS3Buckets(t *testing.T) {
|
||||
body := []byte(`<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
||||
<Owner><ID>x</ID></Owner>
|
||||
<Buckets>
|
||||
<Bucket><Name>my-bucket</Name><CreationDate>2024-01-01T00:00:00Z</CreationDate></Bucket>
|
||||
<Bucket><Name>logs-bucket</Name></Bucket>
|
||||
</Buckets>
|
||||
</ListAllMyBucketsResult>`)
|
||||
out, err := parseS3Buckets(body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out, 2)
|
||||
require.Equal(t, "arn:aws:s3:::my-bucket", out[0].ExternalId)
|
||||
require.Equal(t, "my-bucket", out[0].Name)
|
||||
require.Equal(t, "blob", classifyCloudResourceType(out[0].NativeType))
|
||||
require.Empty(t, out[0].Location)
|
||||
}
|
||||
|
||||
// #62: Lambda ListFunctions JSON 解析 —— ARN 取 FunctionArn;NextMarker 续页。
|
||||
func TestParseLambdaFunctions(t *testing.T) {
|
||||
body := []byte(`{"Functions":[
|
||||
{"FunctionArn":"arn:aws:lambda:us-east-1:123456789012:function:fn-a","FunctionName":"fn-a","Runtime":"go1.x"},
|
||||
{"FunctionArn":"arn:aws:lambda:us-east-1:123456789012:function:fn-b","FunctionName":"fn-b","Runtime":"python3.12"}
|
||||
],"NextMarker":"mk"}`)
|
||||
out, next, err := parseLambdaFunctions(body, "us-east-1")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "mk", next)
|
||||
require.Len(t, out, 2)
|
||||
require.Equal(t, "arn:aws:lambda:us-east-1:123456789012:function:fn-a", out[0].ExternalId)
|
||||
require.Equal(t, "fn-a", out[0].Name)
|
||||
require.Equal(t, "AWS::Lambda::Function", out[0].NativeType)
|
||||
}
|
||||
|
||||
// #62: 合并去重 —— 先到先得(tagged 优先保留),空 ExternalId 丢弃,顺序稳定。
|
||||
func TestMergeDedupeByExternalID(t *testing.T) {
|
||||
tagged := []discoveredCloudResource{
|
||||
{ExternalId: "arn:a", Name: "tagged-a", Metadata: map[string]any{"tags": map[string]any{"k": "v"}}},
|
||||
{ExternalId: "arn:b", Name: "tagged-b"},
|
||||
}
|
||||
ec2 := []discoveredCloudResource{
|
||||
{ExternalId: "arn:a", Name: "ec2-a"}, // 与 tagged 重复 → 保留 tagged
|
||||
{ExternalId: "arn:c", Name: "ec2-c"}, // 未打标签的新资源
|
||||
{ExternalId: "", Name: "empty"}, // 空 id 丢弃
|
||||
}
|
||||
merged := mergeDedupeByExternalID(tagged, ec2)
|
||||
require.Len(t, merged, 3)
|
||||
require.Equal(t, "tagged-a", merged[0].Name) // tagged 优先
|
||||
require.NotNil(t, merged[0].Metadata["tags"]) // 保留更丰富的 tagged 元数据
|
||||
require.Equal(t, "tagged-b", merged[1].Name)
|
||||
require.Equal(t, "ec2-c", merged[2].Name) // 补齐未打标签资源
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// GCP resource discovery (#5). Pure-Go service-account flow (no GCP SDK):
|
||||
// build an RS256-signed JWT assertion from the SA private key, exchange it for an
|
||||
// OAuth2 access token, then call Cloud Asset Inventory searchAllResources (REST)
|
||||
// to enumerate the project's resources. Normalizes assets to discoveredCloudResource.
|
||||
//
|
||||
// Creds (from cloud_account metadata/secret): client_email, private_key (PEM),
|
||||
// project_id. token_uri optional (defaults to Google's).
|
||||
|
||||
type gcpCloudDiscoveryProvider struct{}
|
||||
|
||||
func (gcpCloudDiscoveryProvider) name() string { return "gcp" }
|
||||
|
||||
func (gcpCloudDiscoveryProvider) discover(account model.ResourceBinding, creds map[string]any) ([]discoveredCloudResource, error) {
|
||||
if mapString(creds, "project_id") == "" && strings.TrimSpace(account.ExternalId) != "" {
|
||||
creds["project_id"] = account.ExternalId
|
||||
}
|
||||
return discoverGCP(creds, time.Now(), &http.Client{Timeout: 20 * time.Second})
|
||||
}
|
||||
|
||||
type gcpDiscoveryCredentials struct {
|
||||
ClientEmail string
|
||||
PrivateKey string // PEM
|
||||
ProjectID string
|
||||
TokenURI string
|
||||
}
|
||||
|
||||
func (c gcpDiscoveryCredentials) validate() error {
|
||||
if strings.TrimSpace(c.ClientEmail) == "" {
|
||||
return errors.New("GCP client_email required")
|
||||
}
|
||||
if strings.TrimSpace(c.PrivateKey) == "" {
|
||||
return errors.New("GCP private_key required")
|
||||
}
|
||||
if strings.TrimSpace(c.ProjectID) == "" {
|
||||
return errors.New("GCP project_id required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
// parseRSAPrivateKeyPEM parses a PEM private key (PKCS#8 or PKCS#1) into an RSA key.
|
||||
func parseRSAPrivateKeyPEM(pemStr string) (*rsa.PrivateKey, error) {
|
||||
block, _ := pem.Decode([]byte(strings.TrimSpace(pemStr)))
|
||||
if block == nil {
|
||||
return nil, errors.New("GCP private_key is not valid PEM")
|
||||
}
|
||||
if key, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil {
|
||||
return key, nil
|
||||
}
|
||||
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GCP private_key parse failed: %w", err)
|
||||
}
|
||||
rsaKey, ok := keyAny.(*rsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, errors.New("GCP private_key is not an RSA key")
|
||||
}
|
||||
return rsaKey, nil
|
||||
}
|
||||
|
||||
// buildGCPAssertion builds the RS256-signed JWT assertion for the OAuth2 token
|
||||
// exchange. Pure & deterministic given t — unit-testable with a generated key.
|
||||
func buildGCPAssertion(cred gcpDiscoveryCredentials, scope string, aud string, t time.Time) (string, error) {
|
||||
key, err := parseRSAPrivateKeyPEM(cred.PrivateKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
header, err := common.Marshal(map[string]any{"alg": "RS256", "typ": "JWT"})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
iat := t.UTC().Unix()
|
||||
claims, err := common.Marshal(map[string]any{
|
||||
"iss": cred.ClientEmail,
|
||||
"scope": scope,
|
||||
"aud": aud,
|
||||
"iat": iat,
|
||||
"exp": iat + 3600,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
signingInput := b64url(header) + "." + b64url(claims)
|
||||
digest := sha256.Sum256([]byte(signingInput))
|
||||
sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, digest[:])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return signingInput + "." + b64url(sig), nil
|
||||
}
|
||||
|
||||
func gcpAccessToken(cred gcpDiscoveryCredentials, t time.Time, httpClient *http.Client) (string, error) {
|
||||
tokenURI := strings.TrimSpace(cred.TokenURI)
|
||||
if tokenURI == "" {
|
||||
tokenURI = "https://oauth2.googleapis.com/token"
|
||||
}
|
||||
assertion, err := buildGCPAssertion(cred, "https://www.googleapis.com/auth/cloud-platform", tokenURI, t)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||
form.Set("assertion", assertion)
|
||||
req, err := http.NewRequest(http.MethodPost, tokenURI, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GCP token exchange failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return "", fmt.Errorf("GCP token exchange failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var payload struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := common.Unmarshal(raw, &payload); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(payload.AccessToken) == "" {
|
||||
return "", errors.New("GCP token response missing access_token")
|
||||
}
|
||||
return payload.AccessToken, nil
|
||||
}
|
||||
|
||||
type gcpAsset struct {
|
||||
Name string `json:"name"`
|
||||
AssetType string `json:"assetType"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Location string `json:"location"`
|
||||
}
|
||||
|
||||
type gcpSearchResponse struct {
|
||||
Results []gcpAsset `json:"results"`
|
||||
NextPageToken string `json:"nextPageToken"`
|
||||
}
|
||||
|
||||
// gcpSearchAllResourcesURL 构造 Cloud Asset Inventory searchAllResources 端点。
|
||||
// #5 复审 #3:scope 里的 slash 是路径模板的一部分,**不能** PathEscape 成 %2F —— 正确形如
|
||||
// /v1/projects/<id>:searchAllResources。projectID 仍按单段转义,pageToken 走 query 转义。
|
||||
func gcpSearchAllResourcesURL(projectID, pageToken string) string {
|
||||
endpoint := "https://cloudasset.googleapis.com/v1/projects/" + url.PathEscape(projectID) + ":searchAllResources?pageSize=500"
|
||||
if strings.TrimSpace(pageToken) != "" {
|
||||
endpoint += "&pageToken=" + url.QueryEscape(pageToken)
|
||||
}
|
||||
return endpoint
|
||||
}
|
||||
|
||||
func discoverGCP(creds map[string]any, now time.Time, httpClient *http.Client) ([]discoveredCloudResource, error) {
|
||||
cred := gcpDiscoveryCredentials{
|
||||
ClientEmail: mapString(creds, "client_email"),
|
||||
PrivateKey: mapString(creds, "private_key"),
|
||||
ProjectID: mapString(creds, "project_id"),
|
||||
TokenURI: mapString(creds, "token_uri"),
|
||||
}
|
||||
if err := cred.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, err := gcpAccessToken(cred, now, httpClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := []discoveredCloudResource{}
|
||||
pageToken := ""
|
||||
for page := 0; page < 50; page++ {
|
||||
endpoint := gcpSearchAllResourcesURL(cred.ProjectID, pageToken)
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GCP searchAllResources failed: %w", err)
|
||||
}
|
||||
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("GCP searchAllResources failed (%d): %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var payload gcpSearchResponse
|
||||
if err := common.Unmarshal(raw, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, a := range payload.Results {
|
||||
if strings.TrimSpace(a.Name) == "" {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(a.DisplayName)
|
||||
if name == "" {
|
||||
name = lastPathSegment(a.Name)
|
||||
}
|
||||
out = append(out, discoveredCloudResource{
|
||||
ExternalId: a.Name,
|
||||
Name: name,
|
||||
NativeType: a.AssetType, // e.g. compute.googleapis.com/Instance
|
||||
Location: a.Location,
|
||||
BindingScope: "gcp:" + a.Name,
|
||||
Metadata: map[string]any{"project_id": cred.ProjectID, "asset_type": a.AssetType},
|
||||
})
|
||||
}
|
||||
pageToken = strings.TrimSpace(payload.NextPageToken)
|
||||
if pageToken == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
"github.com/heicode/manager/service"
|
||||
)
|
||||
|
||||
// buildAvailableModelItems maps resolved model names to the client-safe catalog
|
||||
// shape for #25: only model_id / display_name / default. It deliberately omits
|
||||
// channelId / base_url / api_key_ref / provider_type / per-token price — those
|
||||
// stay server-side (Manager). Pure (testable).
|
||||
func buildAvailableModelItems(modelNames []string, defaultModel string) []gin.H {
|
||||
items := make([]gin.H, 0, len(modelNames))
|
||||
for _, name := range modelNames {
|
||||
items = append(items, gin.H{
|
||||
"model_id": name,
|
||||
"display_name": name,
|
||||
"default": name == defaultModel,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// HeicodeAvailableModels: GET /api/heicode/available-models (issue #25).
|
||||
//
|
||||
// The single source of truth for the desktop client's model list: the models
|
||||
// the LOGGED-IN user may use, resolved server-side from the user's usable groups
|
||||
// → group-enabled models (same resolution as GetUserModels). Bound to the user's
|
||||
// own group/subscription; channelId / base_url / api_key / provider type / price
|
||||
// are NEVER exposed. Same auth as /api/heicode/self (UserOrV2DeviceAuth / device
|
||||
// pairing). Client uses this as the only model source (no local presets).
|
||||
func HeicodeAvailableModels(c *gin.Context) {
|
||||
userID := c.GetInt("id")
|
||||
if userID <= 0 {
|
||||
common.ApiErrorMsg(c, "authentication required")
|
||||
return
|
||||
}
|
||||
user, err := model.GetUserCache(userID)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var names []string
|
||||
for group := range service.GetUserUsableGroups(user.Group) {
|
||||
for _, m := range model.GetGroupEnabledModels(group) {
|
||||
if !seen[m] {
|
||||
seen[m] = true
|
||||
names = append(names, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"available_models": buildAvailableModelItems(names, defaultAgentModelID()),
|
||||
})
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// buildAvailableModelItems must emit only the client-safe fields (model_id /
|
||||
// display_name / default), flag exactly the default model, and never leak
|
||||
// server-side routing fields. (#25)
|
||||
func TestBuildAvailableModelItems(t *testing.T) {
|
||||
items := buildAvailableModelItems([]string{"heicode-pro", "heicode-lite"}, "heicode-pro")
|
||||
require.Len(t, items, 2)
|
||||
|
||||
require.Equal(t, "heicode-pro", items[0]["model_id"])
|
||||
require.Equal(t, "heicode-pro", items[0]["display_name"])
|
||||
require.Equal(t, true, items[0]["default"])
|
||||
|
||||
require.Equal(t, "heicode-lite", items[1]["model_id"])
|
||||
require.Equal(t, false, items[1]["default"])
|
||||
|
||||
// client-safe: no server-only fields leak
|
||||
for _, it := range items {
|
||||
for _, banned := range []string{"channelId", "channel_id", "base_url", "api_key", "api_key_ref", "provider_type", "price", "ratio"} {
|
||||
_, present := it[banned]
|
||||
require.False(t, present, "field %q must not be exposed to client", banned)
|
||||
}
|
||||
require.Len(t, it, 3) // exactly model_id, display_name, default
|
||||
}
|
||||
|
||||
// no default in the list -> none flagged
|
||||
none := buildAvailableModelItems([]string{"a", "b"}, "zzz")
|
||||
require.Equal(t, false, none[0]["default"])
|
||||
require.Equal(t, false, none[1]["default"])
|
||||
|
||||
require.Empty(t, buildAvailableModelItems(nil, "x"))
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
)
|
||||
|
||||
// HeicodeConfig: GET /api/heicode/config — client runtime config (issue #24).
|
||||
//
|
||||
// Dedicated config endpoint (client's chosen delivery, #24 §1) so kill switches
|
||||
// propagate within a session WITHOUT re-login: the client polls this and obeys
|
||||
// the latest telemetry.enabled / endpoint. Unauthenticated, non-sensitive global
|
||||
// config — same posture as /api/heicode/capabilities; a natural home for future
|
||||
// client config (feature flags, model-list pointer, …).
|
||||
//
|
||||
// telemetry.enabled defaults FALSE — telemetry stays off (the ingest endpoint
|
||||
// also answers 410) until the privacy policy discloses account-linkable device
|
||||
// IDs and ops flips HEICODE_TELEMETRY_ENABLED=true.
|
||||
func HeicodeConfig(c *gin.Context) {
|
||||
common.ApiSuccess(c, gin.H{
|
||||
"telemetry": gin.H{
|
||||
"enabled": common.GetEnvOrDefaultBool("HEICODE_TELEMETRY_ENABLED", false),
|
||||
"endpoint": "/api/heicode/telemetry/events",
|
||||
"max_batch": common.GetEnvOrDefault("HEICODE_TELEMETRY_MAX_BATCH", telemetryMaxBatch),
|
||||
"flush_interval_sec": common.GetEnvOrDefault("HEICODE_TELEMETRY_FLUSH_INTERVAL_SEC", 30),
|
||||
// Server retention window (#32): events older than this are purged.
|
||||
"retention_days": telemetryRetentionDays(),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// /api/heicode/config carries the telemetry kill-switch block (#24). enabled
|
||||
// must reflect HEICODE_TELEMETRY_ENABLED and the endpoint must match the ingest
|
||||
// route so the client polls the right place.
|
||||
func TestHeicodeConfig_TelemetryBlock(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Setenv("HEICODE_TELEMETRY_ENABLED", "true")
|
||||
rec := httptest.NewRecorder()
|
||||
ctx, _ := gin.CreateTestContext(rec)
|
||||
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/heicode/config", nil)
|
||||
HeicodeConfig(ctx)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
body := rec.Body.String()
|
||||
require.Contains(t, body, `"telemetry"`)
|
||||
require.Contains(t, body, `"endpoint":"/api/heicode/telemetry/events"`)
|
||||
require.Contains(t, body, `"enabled":true`)
|
||||
|
||||
// default OFF when the flag is unset
|
||||
t.Setenv("HEICODE_TELEMETRY_ENABLED", "")
|
||||
rec2 := httptest.NewRecorder()
|
||||
ctx2, _ := gin.CreateTestContext(rec2)
|
||||
ctx2.Request = httptest.NewRequest(http.MethodGet, "/api/heicode/config", nil)
|
||||
HeicodeConfig(ctx2)
|
||||
require.Contains(t, rec2.Body.String(), `"enabled":false`)
|
||||
}
|
||||
@@ -11,44 +11,22 @@ import (
|
||||
"github.com/heicode/manager/setting/operation_setting"
|
||||
)
|
||||
|
||||
// quotaDisplayUnitLabel names the unit the raw quota is denominated in. Returns
|
||||
// the canonical (UPPER-CASE) display-type constant — USD / CNY / TOKENS / CUSTOM
|
||||
// — mirroring the web dashboard. The client uses this only to pick a currency
|
||||
// symbol; the actual conversion is done server-side (display_quota). Returning the
|
||||
// constants verbatim keeps the wire value byte-identical to what the client
|
||||
// compares against, and covers CUSTOM (previously fell through to "USD", so a
|
||||
// custom-currency site mislabelled its display_quota and the client showed "$").
|
||||
func quotaDisplayUnitLabel() string {
|
||||
// quotaToDisplayUnit converts a raw quota integer (DB unit) to the
|
||||
// site-configured display unit (USD / CNY / tokens). Mirrors the
|
||||
// conversion in billing.go so the desktop "balance pill" shows the
|
||||
// same number the user sees in the web dashboard.
|
||||
func quotaToDisplayUnit(raw int) float64 {
|
||||
amount := float64(raw)
|
||||
switch operation_setting.GetQuotaDisplayType() {
|
||||
case operation_setting.QuotaDisplayTypeCNY:
|
||||
return operation_setting.QuotaDisplayTypeCNY
|
||||
return amount / common.QuotaPerUnit * operation_setting.USDExchangeRate
|
||||
case operation_setting.QuotaDisplayTypeTokens:
|
||||
return operation_setting.QuotaDisplayTypeTokens
|
||||
case operation_setting.QuotaDisplayTypeCustom:
|
||||
return operation_setting.QuotaDisplayTypeCustom
|
||||
return amount
|
||||
default:
|
||||
return operation_setting.QuotaDisplayTypeUSD
|
||||
return amount / common.QuotaPerUnit
|
||||
}
|
||||
}
|
||||
|
||||
// displayQuota converts a raw quota counter to the value the user should SEE,
|
||||
// for the current站点 display type — server-side so the client never has to
|
||||
// special-case a mode. This is the contract of record (no hardcoded ×500000):
|
||||
//
|
||||
// USD : raw / quota_per_unit
|
||||
// CNY : raw / quota_per_unit * usd_exchange_rate
|
||||
// CUSTOM : raw / quota_per_unit * custom_currency_exchange_rate
|
||||
// TOKENS : raw (no division)
|
||||
//
|
||||
// usd_exchange_rate is the USD→display-currency multiplier returned alongside,
|
||||
// so clients that prefer raw can recompute the same value.
|
||||
func displayQuota(raw int, label string, perUnit, rate float64) float64 {
|
||||
if label == operation_setting.QuotaDisplayTypeTokens || perUnit <= 0 {
|
||||
return float64(raw)
|
||||
}
|
||||
return float64(raw) / perUnit * rate
|
||||
}
|
||||
|
||||
// GetHeicodeSelf serves GET /api/heicode/self.
|
||||
//
|
||||
// Mounted on TokenAuth so the cc-haha desktop client can read it with
|
||||
@@ -82,30 +60,17 @@ func GetHeicodeSelf(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Conversion params + server-computed display values so every quota_display_type
|
||||
// (USD / CNY / tokens / custom) renders correctly without a client-side hack.
|
||||
label := quotaDisplayUnitLabel()
|
||||
perUnit := float64(common.QuotaPerUnit)
|
||||
rate := operation_setting.GetUsdToCurrencyRate(operation_setting.USDExchangeRate) // USD→1, CNY→7.3, tokens→1
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{
|
||||
"id": user.Id,
|
||||
"username": user.Username,
|
||||
"display_name": user.DisplayName,
|
||||
"group": user.Group,
|
||||
"role": user.Role,
|
||||
// raw counters (NewAPI internal units) + the params to convert them.
|
||||
"quota": user.Quota,
|
||||
"used_quota": user.UsedQuota,
|
||||
"quota_per_unit": common.QuotaPerUnit,
|
||||
"quota_display_type": label, // USD / CNY / TOKENS / CUSTOM (pick currency symbol only)
|
||||
"usd_exchange_rate": rate, // USD→display-currency multiplier (1 for USD/tokens)
|
||||
// ready-to-show values (server applies the display-type formula above).
|
||||
"display_quota": displayQuota(user.Quota, label, perUnit, rate),
|
||||
"display_used_quota": displayQuota(user.UsedQuota, label, perUnit, rate),
|
||||
"request_count": user.RequestCount,
|
||||
"id": user.Id,
|
||||
"username": user.Username,
|
||||
"display_name": user.DisplayName,
|
||||
"group": user.Group,
|
||||
"role": user.Role,
|
||||
"quota": quotaToDisplayUnit(user.Quota),
|
||||
"used_quota": quotaToDisplayUnit(user.UsedQuota),
|
||||
"request_count": user.RequestCount,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/heicode/manager/setting/operation_setting"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// displayQuota is the /api/heicode/self conversion contract: the server applies
|
||||
// the display-type formula so the client never special-cases a mode. Pin all
|
||||
// four modes (USD divides, CNY/CUSTOM scale, TOKENS passes through) and the
|
||||
// divide-by-zero guard.
|
||||
func TestDisplayQuota_AllModes(t *testing.T) {
|
||||
const perUnit = 500000.0
|
||||
cases := []struct {
|
||||
name string
|
||||
raw int
|
||||
label string
|
||||
rate float64
|
||||
want float64
|
||||
}{
|
||||
{"USD divides by per-unit", 1_000_000, operation_setting.QuotaDisplayTypeUSD, 1, 2},
|
||||
{"CNY multiplies by usd rate", 1_000_000, operation_setting.QuotaDisplayTypeCNY, 7.3, 14.6},
|
||||
{"CUSTOM uses custom rate", 1_000_000, operation_setting.QuotaDisplayTypeCustom, 2.5, 5},
|
||||
{"TOKENS returns raw, no division", 1_000_000, operation_setting.QuotaDisplayTypeTokens, 1, 1_000_000},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.InDelta(t, tc.want, displayQuota(tc.raw, tc.label, perUnit, tc.rate), 1e-9)
|
||||
})
|
||||
}
|
||||
|
||||
// per_unit<=0 must never divide by zero — return raw unchanged.
|
||||
require.Equal(t, float64(1_000_000), displayQuota(1_000_000, operation_setting.QuotaDisplayTypeUSD, 0, 1))
|
||||
}
|
||||
|
||||
// quotaDisplayUnitLabel must echo the canonical UPPER-CASE constant for every
|
||||
// mode. The CUSTOM case is the regression guard: it previously fell through to
|
||||
// "USD", mislabelling a custom-currency site's display_quota.
|
||||
func TestQuotaDisplayUnitLabel_CanonicalAndCustom(t *testing.T) {
|
||||
gs := operation_setting.GetGeneralSetting()
|
||||
orig := gs.QuotaDisplayType
|
||||
t.Cleanup(func() { gs.QuotaDisplayType = orig })
|
||||
|
||||
for _, want := range []string{
|
||||
operation_setting.QuotaDisplayTypeUSD,
|
||||
operation_setting.QuotaDisplayTypeCNY,
|
||||
operation_setting.QuotaDisplayTypeTokens,
|
||||
operation_setting.QuotaDisplayTypeCustom,
|
||||
} {
|
||||
gs.QuotaDisplayType = want
|
||||
require.Equal(t, want, quotaDisplayUnitLabel())
|
||||
}
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// Client error-telemetry ingest (issue #24). Contract: winos#23
|
||||
// docs/integration/telemetry-ingest-endpoint.md. Body is a TOP-LEVEL JSON array
|
||||
// of events (NOT wrapped). Auth reuses device-pairing (UserOrV2DeviceAuth). It
|
||||
// is diagnostic traffic: NEVER bills (separate table, no consume log / quota).
|
||||
//
|
||||
// Default-OFF: until the privacy policy discloses account-linkable device IDs
|
||||
// and endpoint delivery is agreed, HEICODE_TELEMETRY_ENABLED stays false and the
|
||||
// endpoint answers 410 (kill switch) so the client stops sending.
|
||||
|
||||
const (
|
||||
telemetryMaxBatch = 20
|
||||
telemetryMaxBodySize = 256 * 1024
|
||||
headerDeviceID = "X-Heicode-Device-Id"
|
||||
// Per-field hard cap after redaction (#32): a backstop so a single event
|
||||
// can't park an unbounded blob in the telemetry store even within batch
|
||||
// limits. stack_top / context are truncated past this many bytes.
|
||||
telemetryMaxFieldBytes = 8 * 1024
|
||||
)
|
||||
|
||||
// telemetryContextAllowedKeys whitelists the non-content diagnostic keys the
|
||||
// client may attach to an event's `context` (#32). Anything else is dropped
|
||||
// before persistence, so a client regression can't land arbitrary — possibly
|
||||
// identifying — JSON (prompts, code, tokens, emails, full file paths, raw IPs)
|
||||
// in the telemetry store. Keep in sync with the client telemetry contract
|
||||
// (winos#23) and docs/integration/heicode-desktop-client-api.md. Additions must
|
||||
// be reviewed against the "no identifying content" rule in issue #32.
|
||||
var telemetryContextAllowedKeys = map[string]bool{
|
||||
"route": true, // logical UI route, e.g. "chat" (no params)
|
||||
"retryable": true, // bool
|
||||
"phase": true, // lifecycle phase enum
|
||||
"exit_code": true, // process exit code (int)
|
||||
"duration_ms": true, // numeric timing
|
||||
"attempt": true, // retry attempt count
|
||||
}
|
||||
|
||||
// filterTelemetryContext keeps only whitelisted keys from the client-supplied
|
||||
// context object, then redacts and size-caps the result (#32). Returns "" when
|
||||
// the context is empty, unparseable, or has no allowed keys — telemetry is
|
||||
// best-effort diagnostics, so dropping an unrecognized payload is preferable to
|
||||
// storing arbitrary JSON.
|
||||
func filterTelemetryContext(raw json.RawMessage) string {
|
||||
if len(raw) == 0 {
|
||||
return ""
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := common.Unmarshal(raw, &obj); err != nil {
|
||||
return "" // not an object (or malformed) -> drop
|
||||
}
|
||||
filtered := make(map[string]json.RawMessage, len(obj))
|
||||
for k, v := range obj {
|
||||
if telemetryContextAllowedKeys[k] {
|
||||
filtered[k] = v
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return ""
|
||||
}
|
||||
b, err := common.Marshal(filtered)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return capTelemetryField(model.RedactText(string(b)))
|
||||
}
|
||||
|
||||
// capTelemetryField truncates an already-redacted field to telemetryMaxFieldBytes.
|
||||
func capTelemetryField(s string) string {
|
||||
if len(s) <= telemetryMaxFieldBytes {
|
||||
return s
|
||||
}
|
||||
return s[:telemetryMaxFieldBytes]
|
||||
}
|
||||
|
||||
type telemetryEventIn struct {
|
||||
ClientId string `json:"client_id"`
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
AppVersion string `json:"app_version"`
|
||||
Platform string `json:"platform"`
|
||||
OsVersion string `json:"os_version"`
|
||||
Arch string `json:"arch"`
|
||||
Locale string `json:"locale"`
|
||||
ErrorCategory string `json:"error_category"`
|
||||
ErrorCode string `json:"error_code"`
|
||||
ErrorMessageHash string `json:"error_message_hash"`
|
||||
StackHash string `json:"stack_hash"`
|
||||
StackTop []string `json:"stack_top"`
|
||||
Context json.RawMessage `json:"context"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
SessionSeq int `json:"session_seq"`
|
||||
}
|
||||
|
||||
// parseTelemetryBatch unmarshals the top-level JSON array of events. Pure.
|
||||
func parseTelemetryBatch(body []byte) ([]telemetryEventIn, error) {
|
||||
var events []telemetryEventIn
|
||||
if err := common.Unmarshal(body, &events); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return events, nil
|
||||
}
|
||||
|
||||
// toModel maps an ingested event to its row. Deliberately tolerant (maximize
|
||||
// capture): unknown enums / sentinel app_version / missing fields are stored
|
||||
// as-is; schema_version defaults to 1; stack_top/context are kept as JSON text.
|
||||
func (e telemetryEventIn) toModel(userID int, deviceID string, now int64) model.TelemetryEvent {
|
||||
clientID := strings.TrimSpace(e.ClientId)
|
||||
if clientID == "" {
|
||||
clientID = deviceID
|
||||
}
|
||||
sv := e.SchemaVersion
|
||||
if sv <= 0 {
|
||||
sv = 1
|
||||
}
|
||||
// Defense-in-depth (#24 review): the client already sanitizes, but we also
|
||||
// redact stack_top / context server-side — strip sk-/Bearer/URL tokens/JSON
|
||||
// secret fields before persistence, so a producer bug can't land plaintext
|
||||
// secrets in the telemetry store.
|
||||
stackTopJSON := ""
|
||||
if len(e.StackTop) > 0 {
|
||||
if b, err := common.Marshal(e.StackTop); err == nil {
|
||||
stackTopJSON = capTelemetryField(model.RedactText(string(b)))
|
||||
}
|
||||
}
|
||||
// #32: context is restricted to a key whitelist (drop arbitrary/identifying
|
||||
// JSON), then redacted and size-capped.
|
||||
ctxJSON := filterTelemetryContext(e.Context)
|
||||
return model.TelemetryEvent{
|
||||
ReceivedAt: now,
|
||||
UserId: userID,
|
||||
ClientId: clientID,
|
||||
SchemaVersion: sv,
|
||||
AppVersion: e.AppVersion,
|
||||
Platform: e.Platform,
|
||||
OsVersion: e.OsVersion,
|
||||
Arch: e.Arch,
|
||||
Locale: e.Locale,
|
||||
ErrorCategory: e.ErrorCategory,
|
||||
ErrorCode: e.ErrorCode,
|
||||
ErrorMessageHash: e.ErrorMessageHash,
|
||||
StackHash: e.StackHash,
|
||||
StackTopJSON: stackTopJSON,
|
||||
ContextJSON: ctxJSON,
|
||||
EventTimestamp: e.Timestamp,
|
||||
SessionSeq: e.SessionSeq,
|
||||
}
|
||||
}
|
||||
|
||||
// HeicodeTelemetryEvents: POST /api/heicode/telemetry/events (issue #24).
|
||||
func HeicodeTelemetryEvents(c *gin.Context) {
|
||||
// Kill switch / not-live gate (default OFF). 410 => client stops sending.
|
||||
if !common.GetEnvOrDefaultBool("HEICODE_TELEMETRY_ENABLED", false) {
|
||||
c.JSON(http.StatusGone, gin.H{"success": false, "message": "telemetry ingest disabled"})
|
||||
return
|
||||
}
|
||||
userID := c.GetInt("id")
|
||||
if userID <= 0 {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "message": "authentication required"})
|
||||
return
|
||||
}
|
||||
deviceID := strings.TrimSpace(c.GetHeader(headerDeviceID))
|
||||
if deviceID == "" {
|
||||
// Telemetry is desktop-only (device-paired); reject session-only callers.
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "telemetry requires a paired device"})
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(c.Request.Body, telemetryMaxBodySize+1))
|
||||
if len(body) > telemetryMaxBodySize {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"success": false, "message": "telemetry batch too large"})
|
||||
return
|
||||
}
|
||||
events, err := parseTelemetryBatch(body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": "invalid telemetry body (expect a JSON array of events)"})
|
||||
return
|
||||
}
|
||||
if len(events) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "accepted": 0})
|
||||
return
|
||||
}
|
||||
if len(events) > telemetryMaxBatch {
|
||||
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"success": false, "message": "telemetry batch exceeds 20 events"})
|
||||
return
|
||||
}
|
||||
now := common.GetTimestamp()
|
||||
rows := make([]model.TelemetryEvent, 0, len(events))
|
||||
for _, e := range events {
|
||||
// Anti-spoof: a present client_id must equal the verified paired device.
|
||||
if cid := strings.TrimSpace(e.ClientId); cid != "" && cid != deviceID {
|
||||
c.JSON(http.StatusForbidden, gin.H{"success": false, "message": "client_id does not match paired device"})
|
||||
return
|
||||
}
|
||||
rows = append(rows, e.toModel(userID, deviceID, now))
|
||||
}
|
||||
if err := model.InsertTelemetryEvents(rows); err != nil {
|
||||
common.SysLog("telemetry ingest persist failed: " + err.Error())
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": "telemetry persist failed"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "accepted": len(rows)})
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Uses the client's authoritative simulation batch (winos#23 §4) verbatim as the
|
||||
// ingest fixture, so HM parsing/mapping stays in lock-step with what the client
|
||||
// actually sends (#24).
|
||||
const telemetryClientFixtureBatch = `[
|
||||
{"client_id":"d3f1c0a2-7b44-4e19-9a8c-2f6b1e0c5a77","app_version":"0.5.0","platform":"win32","os_version":"10.0.26100","arch":"x64","locale":"zh-CN","error_category":"ui_crash","error_code":"RENDERER_ERROR","error_message_hash":"9f2a7c1b4e8d","stack_hash":"a1b2c3d4e5f6","stack_top":["at MessageList (MessageList.tsx:212:9)","at renderWithHooks (react-dom.production.min.js:0:0)"],"context":{"route":"chat"},"timestamp":"2026-06-09T07:21:33.123Z","session_seq":1},
|
||||
{"client_id":"d3f1c0a2-7b44-4e19-9a8c-2f6b1e0c5a77","app_version":"0.0.0-heicode-local","platform":"win32","os_version":"10.0.26100","arch":"x64","locale":"zh-CN","error_category":"cli_startup_failed","error_code":"CLI_STARTUP_TIMEOUT","error_message_hash":"5c8e1f0a9b2d","stack_hash":"000000000000","stack_top":[],"context":{"retryable":true},"timestamp":"2026-06-09T07:22:01.880Z","session_seq":2}
|
||||
]`
|
||||
|
||||
func TestParseTelemetryBatch_ClientFixture(t *testing.T) {
|
||||
events, err := parseTelemetryBatch([]byte(telemetryClientFixtureBatch))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, events, 2)
|
||||
|
||||
require.Equal(t, "d3f1c0a2-7b44-4e19-9a8c-2f6b1e0c5a77", events[0].ClientId)
|
||||
require.Equal(t, "ui_crash", events[0].ErrorCategory)
|
||||
require.Equal(t, "9f2a7c1b4e8d", events[0].ErrorMessageHash)
|
||||
require.Len(t, events[0].StackTop, 2)
|
||||
require.Equal(t, 1, events[0].SessionSeq)
|
||||
|
||||
// sentinel app_version is parsed as-is (must be tolerated on ingest)
|
||||
require.Equal(t, "0.0.0-heicode-local", events[1].AppVersion)
|
||||
}
|
||||
|
||||
func TestTelemetryToModel_MappingAndDefaults(t *testing.T) {
|
||||
events, err := parseTelemetryBatch([]byte(telemetryClientFixtureBatch))
|
||||
require.NoError(t, err)
|
||||
|
||||
const uid, dev, now = 4242, "d3f1c0a2-7b44-4e19-9a8c-2f6b1e0c5a77", int64(1700000000)
|
||||
m0 := events[0].toModel(uid, dev, now)
|
||||
require.Equal(t, uid, m0.UserId) // device -> account link recorded
|
||||
require.Equal(t, dev, m0.ClientId)
|
||||
require.EqualValues(t, now, m0.ReceivedAt) // server time, not client
|
||||
require.Equal(t, "ui_crash", m0.ErrorCategory)
|
||||
require.Equal(t, 1, m0.SchemaVersion) // missing schema_version defaults to 1
|
||||
require.JSONEq(t, `{"route":"chat"}`, m0.ContextJSON)
|
||||
require.JSONEq(t, `["at MessageList (MessageList.tsx:212:9)","at renderWithHooks (react-dom.production.min.js:0:0)"]`, m0.StackTopJSON)
|
||||
require.Equal(t, "2026-06-09T07:21:33.123Z", m0.EventTimestamp)
|
||||
|
||||
// empty client_id falls back to the verified device id
|
||||
ev := telemetryEventIn{ClientId: "", SchemaVersion: 0}
|
||||
m := ev.toModel(uid, dev, now)
|
||||
require.Equal(t, dev, m.ClientId)
|
||||
require.Equal(t, 1, m.SchemaVersion)
|
||||
require.Equal(t, "", m.StackTopJSON)
|
||||
require.Equal(t, "", m.ContextJSON)
|
||||
}
|
||||
|
||||
func TestParseTelemetryBatch_RejectsNonArray(t *testing.T) {
|
||||
_, err := parseTelemetryBatch([]byte(`{"events":[]}`)) // wrapped object, not the contract
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Defense-in-depth (#24 review §3): even if the client failed to sanitize, the
|
||||
// server must strip plaintext secrets from stack_top / context before storing.
|
||||
func TestTelemetryToModel_RedactsSecrets(t *testing.T) {
|
||||
ev := telemetryEventIn{
|
||||
ClientId: "dev-1",
|
||||
StackTop: []string{"at boom (auth.ts) key=sk-abcDEF1234567890"},
|
||||
// "route" is whitelisted (#32) so it survives the field filter; redaction
|
||||
// must still strip the Bearer token carried inside an allowed key.
|
||||
Context: json.RawMessage(`{"route":"Authorization: Bearer aZ09tokenVALUE","retryable":true}`),
|
||||
}
|
||||
m := ev.toModel(7, "dev-1", 1700000000)
|
||||
|
||||
require.NotContains(t, m.StackTopJSON, "sk-abcDEF1234567890", "sk- secret must be redacted in stack_top")
|
||||
require.Contains(t, m.StackTopJSON, "REDACTED")
|
||||
require.NotContains(t, m.ContextJSON, "aZ09tokenVALUE", "Bearer token must be redacted in context")
|
||||
require.Contains(t, m.ContextJSON, "retryable") // non-secret whitelisted content preserved
|
||||
}
|
||||
|
||||
// #32: context must be restricted to a key whitelist so a client regression
|
||||
// cannot land arbitrary/identifying JSON in the telemetry store.
|
||||
func TestFilterTelemetryContext_Whitelist(t *testing.T) {
|
||||
// allowed keys kept, unknown keys (incl. potentially identifying) dropped
|
||||
out := filterTelemetryContext(json.RawMessage(
|
||||
`{"route":"chat","retryable":true,"email":"a@b.com","file":"C:/Users/x/secret.go","prompt":"hi"}`))
|
||||
require.Contains(t, out, "route")
|
||||
require.Contains(t, out, "retryable")
|
||||
require.NotContains(t, out, "email")
|
||||
require.NotContains(t, out, "a@b.com")
|
||||
require.NotContains(t, out, "secret.go")
|
||||
require.NotContains(t, out, "prompt")
|
||||
|
||||
// no allowed keys -> dropped entirely
|
||||
require.Equal(t, "", filterTelemetryContext(json.RawMessage(`{"email":"a@b.com"}`)))
|
||||
// non-object / malformed -> dropped
|
||||
require.Equal(t, "", filterTelemetryContext(json.RawMessage(`"a string"`)))
|
||||
require.Equal(t, "", filterTelemetryContext(json.RawMessage(`not json`)))
|
||||
require.Equal(t, "", filterTelemetryContext(nil))
|
||||
}
|
||||
|
||||
// #32: per-field size cap is a backstop against unbounded blobs.
|
||||
func TestCapTelemetryField(t *testing.T) {
|
||||
require.Equal(t, "short", capTelemetryField("short"))
|
||||
big := make([]byte, telemetryMaxFieldBytes+100)
|
||||
for i := range big {
|
||||
big[i] = 'a'
|
||||
}
|
||||
require.Len(t, capTelemetryField(string(big)), telemetryMaxFieldBytes)
|
||||
}
|
||||
@@ -627,23 +627,12 @@ func UpsertResourceSecret(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
// Issue #4 rotation: if this binding already has a secret, rotate it (Azure
|
||||
// stores a new version under the same ref/name); otherwise create it. Either
|
||||
// way the plaintext credential only lives in Key Vault, never in our DB.
|
||||
var secretRef string
|
||||
rotated := strings.TrimSpace(resource.SecretRef) != ""
|
||||
if rotated {
|
||||
secretRef, err = client.rotateSecret(resource.SecretRef, payload.Data)
|
||||
} else {
|
||||
secretRef, err = client.putSecret(resourceSecretName(resource), payload.Data)
|
||||
}
|
||||
secretName := resourceSecretName(resource)
|
||||
secretRef, err := client.putSecret(secretName, payload.Data)
|
||||
if err != nil {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
if rotated {
|
||||
common.SysLog(fmt.Sprintf("UpsertResourceSecret: rotated KV secret for binding %d (user %d) — new version", resource.Id, userId))
|
||||
}
|
||||
resource.SecretRef = secretRef
|
||||
if err := model.DB.Save(&resource).Error; err != nil {
|
||||
common.ApiError(c, err)
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// Secret lifecycle (issue #4). Beyond the revoke-time delete already shipped in
|
||||
// DeleteResource, this file closes the remaining lifecycle:
|
||||
// 1. revokeUserResourceSecrets — on account disable/delete, soft-delete ALL of
|
||||
// the user's resource-bound Key Vault secrets (full cleanup, not just DB).
|
||||
// 2. StartSecretPurgeTask — daily job that PERMANENTLY purges vault secrets
|
||||
// soft-deleted ≥ retention (default 30 days): the "30-day delete / render
|
||||
// unrecoverable" requirement.
|
||||
// 3. rotation — UpsertResourceSecret re-PUTs a credential, which Azure stores
|
||||
// as a new version (rotateSecret == putSecret); see resource.go.
|
||||
|
||||
// revokeUserResourceSecrets revokes every still-active resource binding owned by
|
||||
// userID and best-effort soft-deletes its Key Vault material, so disabling or
|
||||
// deleting an account never leaves live credentials in the vault. The DB revoke
|
||||
// is authoritative and committed first; a Key Vault outage is logged but never
|
||||
// blocks account management. Returns the number of secrets soft-deleted.
|
||||
func revokeUserResourceSecrets(userID int) int {
|
||||
if userID <= 0 || model.DB == nil {
|
||||
return 0
|
||||
}
|
||||
var bindings []model.ResourceBinding
|
||||
if err := model.DB.
|
||||
Where("user_id = ? AND secret_ref <> '' AND status <> ?", userID, "revoked").
|
||||
Find(&bindings).Error; err != nil {
|
||||
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: list bindings for user %d failed: %s", userID, err.Error()))
|
||||
return 0
|
||||
}
|
||||
if len(bindings) == 0 {
|
||||
return 0
|
||||
}
|
||||
ids := make([]int, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
ids = append(ids, b.Id)
|
||||
}
|
||||
if err := model.DB.Model(&model.ResourceBinding{}).Where("id IN ?", ids).
|
||||
Update("status", "revoked").Error; err != nil {
|
||||
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: mark revoked for user %d failed: %s", userID, err.Error()))
|
||||
}
|
||||
store, sErr := newSecretStoreClientFromEnv()
|
||||
if sErr != nil {
|
||||
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: secret store unavailable, %d KV secrets NOT deleted for user %d: %s", len(bindings), userID, sErr.Error()))
|
||||
return 0
|
||||
}
|
||||
deleted := 0
|
||||
for _, b := range bindings {
|
||||
if dErr := store.deleteSecret(b.SecretRef); dErr != nil {
|
||||
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: KV delete failed for binding %d (user %d): %s", b.Id, userID, dErr.Error()))
|
||||
continue
|
||||
}
|
||||
deleted++
|
||||
}
|
||||
common.SysLog(fmt.Sprintf("revokeUserResourceSecrets: user %d — %d bindings revoked, %d KV secrets soft-deleted", userID, len(bindings), deleted))
|
||||
return deleted
|
||||
}
|
||||
|
||||
// ── 30-day purge ────────────────────────────────────────────────────────────
|
||||
|
||||
type deletedSecretInfo struct {
|
||||
name string
|
||||
deletedDate int64 // unix seconds, from Azure KV
|
||||
}
|
||||
|
||||
// parseDeletedSecretsPage parses one page of Azure KV GET /deletedsecrets and
|
||||
// returns the secrets + the nextLink (empty when no more pages). Pure (testable).
|
||||
func parseDeletedSecretsPage(body []byte) ([]deletedSecretInfo, string, error) {
|
||||
var page struct {
|
||||
Value []struct {
|
||||
RecoveryID string `json:"recoveryId"`
|
||||
ID string `json:"id"`
|
||||
DeletedDate int64 `json:"deletedDate"`
|
||||
} `json:"value"`
|
||||
NextLink string `json:"nextLink"`
|
||||
}
|
||||
if err := common.Unmarshal(body, &page); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
items := make([]deletedSecretInfo, 0, len(page.Value))
|
||||
for _, v := range page.Value {
|
||||
name := lastPathSegment(v.RecoveryID)
|
||||
if name == "" {
|
||||
name = lastPathSegment(v.ID)
|
||||
}
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, deletedSecretInfo{name: name, deletedDate: v.DeletedDate})
|
||||
}
|
||||
return items, strings.TrimSpace(page.NextLink), nil
|
||||
}
|
||||
|
||||
func lastPathSegment(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(raw); err == nil && u.Path != "" {
|
||||
raw = u.Path
|
||||
}
|
||||
raw = strings.Trim(raw, "/")
|
||||
if i := strings.LastIndex(raw, "/"); i >= 0 {
|
||||
raw = raw[i+1:]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// secretExpired reports whether a secret soft-deleted at deletedDate (unix sec)
|
||||
// is at or past retentionDays old relative to nowUnix. Pure (testable).
|
||||
func secretExpired(deletedDate int64, retentionDays int, nowUnix int64) bool {
|
||||
if deletedDate <= 0 || retentionDays <= 0 {
|
||||
return false
|
||||
}
|
||||
return nowUnix-deletedDate >= int64(retentionDays)*86400
|
||||
}
|
||||
|
||||
// listDeletedSecrets returns every soft-deleted secret in the vault (paginated).
|
||||
func (s secretStoreClient) listDeletedSecrets() ([]deletedSecretInfo, error) {
|
||||
token, err := s.accessToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/deletedsecrets?api-version=7.4", s.vaultURL)
|
||||
var out []deletedSecretInfo
|
||||
for endpoint != "" {
|
||||
req, rErr := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||
if rErr != nil {
|
||||
return nil, rErr
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, dErr := s.client.Do(req)
|
||||
if dErr != nil {
|
||||
return nil, dErr
|
||||
}
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
message := readSecretStoreError(bytes.NewReader(body))
|
||||
if message == "" {
|
||||
message = resp.Status
|
||||
}
|
||||
return nil, fmt.Errorf("Azure Key Vault list deleted secrets failed: %s", message)
|
||||
}
|
||||
items, next, pErr := parseDeletedSecretsPage(body)
|
||||
if pErr != nil {
|
||||
return nil, pErr
|
||||
}
|
||||
out = append(out, items...)
|
||||
endpoint = next
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// purgeExpiredVaultSecrets permanently purges every soft-deleted vault secret
|
||||
// older than retentionDays. An optional namePrefix restricts purging to secrets
|
||||
// HM manages (empty = all soft-deleted secrets in the vault). Returns (purged,
|
||||
// scanned). Purge-protected vaults safely no-op (purgeSecret treats 403 as ok).
|
||||
func purgeExpiredVaultSecrets(retentionDays int, namePrefix string, nowUnix int64) (purged int, scanned int, err error) {
|
||||
store, err := newSecretStoreClientFromEnv()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
items, err := store.listDeletedSecrets()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
prefix := strings.TrimSpace(namePrefix)
|
||||
for _, it := range items {
|
||||
if prefix != "" && !strings.HasPrefix(it.name, prefix) {
|
||||
continue
|
||||
}
|
||||
scanned++
|
||||
if !secretExpired(it.deletedDate, retentionDays, nowUnix) {
|
||||
continue
|
||||
}
|
||||
if pErr := store.purgeSecret(store.secretRef(it.name)); pErr != nil {
|
||||
common.SysLog(fmt.Sprintf("purgeExpiredVaultSecrets: purge %q failed: %s", it.name, pErr.Error()))
|
||||
continue
|
||||
}
|
||||
common.SysLog(fmt.Sprintf("purgeExpiredVaultSecrets: purged %q (soft-deleted %d days ago)", it.name, (nowUnix-it.deletedDate)/86400))
|
||||
purged++
|
||||
}
|
||||
return purged, scanned, nil
|
||||
}
|
||||
|
||||
var secretPurgeTaskOnce sync.Once
|
||||
|
||||
// StartSecretPurgeTask launches the daily background purge of vault secrets that
|
||||
// have been soft-deleted ≥ HEICODE_SECRET_PURGE_RETENTION_DAYS (default 30) ago,
|
||||
// satisfying issue #4's "render unrecoverable within 30 days" requirement.
|
||||
// Disabled by HEICODE_SECRET_PURGE_ENABLED=false; no-ops when no vault is
|
||||
// configured. HEICODE_SECRET_PURGE_NAME_PREFIX scopes purging to HM-managed
|
||||
// secrets when the vault is shared.
|
||||
func StartSecretPurgeTask() {
|
||||
secretPurgeTaskOnce.Do(func() {
|
||||
if !common.GetEnvOrDefaultBool("HEICODE_SECRET_PURGE_ENABLED", true) {
|
||||
common.SysLog("secret purge task disabled (HEICODE_SECRET_PURGE_ENABLED=false)")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(os.Getenv("AZURE_KEY_VAULT_URL")) == "" {
|
||||
return // no vault configured — nothing to purge
|
||||
}
|
||||
// #33: a destructive purge that scans the WHOLE vault is only safe when
|
||||
// HM owns the vault exclusively. If no name prefix scopes purging to
|
||||
// HM-managed secrets, require an explicit opt-in (HEICODE_SECRET_PURGE_
|
||||
// VAULT_EXCLUSIVE=true) so HM never permanently purges another tenant's
|
||||
// soft-deleted secrets that happen to live in a shared vault.
|
||||
purgePrefix := strings.TrimSpace(common.GetEnvOrDefaultString("HEICODE_SECRET_PURGE_NAME_PREFIX", ""))
|
||||
vaultExclusive := common.GetEnvOrDefaultBool("HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE", false)
|
||||
allowed, scopeDesc := secretPurgeScopeAllowed(purgePrefix, vaultExclusive)
|
||||
if !allowed {
|
||||
common.SysLog("secret purge task NOT started: " + scopeDesc)
|
||||
return
|
||||
}
|
||||
common.SysLog("secret purge scope: " + scopeDesc)
|
||||
intervalHours := common.GetEnvOrDefault("HEICODE_SECRET_PURGE_INTERVAL_HOURS", 24)
|
||||
if intervalHours < 1 {
|
||||
intervalHours = 24
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute) // avoid startup churn
|
||||
runSecretPurgeOnce()
|
||||
ticker := time.NewTicker(time.Duration(intervalHours) * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runSecretPurgeOnce()
|
||||
}
|
||||
}()
|
||||
common.SysLog(fmt.Sprintf("secret purge task started: retention=%dd interval=%dh", secretPurgeRetentionDays(), intervalHours))
|
||||
})
|
||||
}
|
||||
|
||||
// secretPurgeScopeAllowed decides whether the destructive vault purge may run,
|
||||
// given the configured name prefix and the vault-exclusive opt-in (#33). A purge
|
||||
// that scans the WHOLE vault (empty prefix) is only safe when HM owns the vault
|
||||
// exclusively, so it must be explicitly opted in. Returns the decision plus a
|
||||
// human-readable scope/refusal description for the startup log. Pure helper for
|
||||
// unit testing without touching process env.
|
||||
func secretPurgeScopeAllowed(namePrefix string, vaultExclusive bool) (bool, string) {
|
||||
prefix := strings.TrimSpace(namePrefix)
|
||||
if prefix == "" {
|
||||
if !vaultExclusive {
|
||||
return false, "HEICODE_SECRET_PURGE_NAME_PREFIX is empty and HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE!=true — refusing to purge an entire (possibly shared) vault. Set a name prefix to scope to HM-managed secrets, or set HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE=true only if this vault is exclusive to HM."
|
||||
}
|
||||
return true, "ENTIRE vault (HEICODE_SECRET_PURGE_VAULT_EXCLUSIVE=true, no name prefix)"
|
||||
}
|
||||
return true, "secrets with name prefix '" + prefix + "'"
|
||||
}
|
||||
|
||||
func secretPurgeRetentionDays() int {
|
||||
d := common.GetEnvOrDefault("HEICODE_SECRET_PURGE_RETENTION_DAYS", 30)
|
||||
if d < 1 {
|
||||
d = 30
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func runSecretPurgeOnce() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
common.SysLog(fmt.Sprintf("secret purge task panic recovered: %v", r))
|
||||
}
|
||||
}()
|
||||
prefix := common.GetEnvOrDefaultString("HEICODE_SECRET_PURGE_NAME_PREFIX", "")
|
||||
purged, scanned, err := purgeExpiredVaultSecrets(secretPurgeRetentionDays(), prefix, time.Now().Unix())
|
||||
if err != nil {
|
||||
common.SysLog("secret purge task: " + err.Error())
|
||||
return
|
||||
}
|
||||
if purged > 0 {
|
||||
common.SysLog(fmt.Sprintf("secret purge task: purged %d of %d scanned deleted secrets past %d-day retention", purged, scanned, secretPurgeRetentionDays()))
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/heicode/manager/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// #33: an unscoped (empty-prefix) purge must be refused unless the operator
|
||||
// explicitly declares the vault exclusive to HM, so HM never permanently purges
|
||||
// another tenant's soft-deleted secrets in a shared vault.
|
||||
func TestSecretPurgeScopeAllowed(t *testing.T) {
|
||||
allowed, desc := secretPurgeScopeAllowed("", false)
|
||||
require.False(t, allowed)
|
||||
require.Contains(t, desc, "refusing to purge an entire")
|
||||
|
||||
allowed, desc = secretPurgeScopeAllowed(" ", true)
|
||||
require.True(t, allowed)
|
||||
require.Contains(t, desc, "ENTIRE vault")
|
||||
|
||||
allowed, desc = secretPurgeScopeAllowed("heicode-", false)
|
||||
require.True(t, allowed)
|
||||
require.Contains(t, desc, "heicode-")
|
||||
}
|
||||
|
||||
func TestParseDeletedSecretsPage(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"value": [
|
||||
{"recoveryId":"https://v.vault.azure.net/deletedsecrets/users-7-bindings-abc","id":"https://v.vault.azure.net/secrets/users-7-bindings-abc","deletedDate":1700000000},
|
||||
{"id":"https://v.vault.azure.net/secrets/foo","deletedDate":1700001000}
|
||||
],
|
||||
"nextLink":"https://v.vault.azure.net/deletedsecrets?api-version=7.4&$skiptoken=xyz"
|
||||
}`)
|
||||
items, next, err := parseDeletedSecretsPage(body)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, items, 2)
|
||||
require.Equal(t, "users-7-bindings-abc", items[0].name) // from recoveryId
|
||||
require.EqualValues(t, 1700000000, items[0].deletedDate)
|
||||
require.Equal(t, "foo", items[1].name) // recoveryId absent -> falls back to id
|
||||
require.EqualValues(t, 1700001000, items[1].deletedDate)
|
||||
require.NotEmpty(t, next)
|
||||
}
|
||||
|
||||
func TestSecretExpired(t *testing.T) {
|
||||
const now int64 = 1_000_000_000
|
||||
const day int64 = 86400
|
||||
require.True(t, secretExpired(now-31*day, 30, now), "31d old past 30d retention")
|
||||
require.True(t, secretExpired(now-30*day, 30, now), "exactly 30d is past retention")
|
||||
require.False(t, secretExpired(now-29*day, 30, now), "29d old still within retention")
|
||||
require.False(t, secretExpired(0, 30, now), "unknown deletedDate never expires")
|
||||
require.False(t, secretExpired(now-100*day, 0, now), "retention<=0 disables purge")
|
||||
}
|
||||
|
||||
func TestLastPathSegment(t *testing.T) {
|
||||
require.Equal(t, "abc", lastPathSegment("https://v.vault.azure.net/deletedsecrets/abc"))
|
||||
require.Equal(t, "x", lastPathSegment("https://v.vault.azure.net/secrets/x/"))
|
||||
require.Equal(t, "plain", lastPathSegment("plain"))
|
||||
require.Equal(t, "", lastPathSegment(""))
|
||||
}
|
||||
|
||||
// With no Key Vault configured, revokeUserResourceSecrets must still flip the
|
||||
// user's secret-bearing bindings to revoked (DB is authoritative) and leave
|
||||
// other users / secret-less bindings untouched.
|
||||
func TestRevokeUserResourceSecrets_MarksRevoked(t *testing.T) {
|
||||
setupResourceControllerTestDB(t)
|
||||
require.NoError(t, model.DB.AutoMigrate(&model.ResourceBinding{}))
|
||||
t.Setenv("AZURE_KEY_VAULT_URL", "") // force secret store unconfigured -> KV skipped
|
||||
|
||||
mk := func(uid int, secretRef string) model.ResourceBinding {
|
||||
b := model.ResourceBinding{UserId: uid, Name: "n", ResourceType: "git", SecretRef: secretRef, Status: "active"}
|
||||
require.NoError(t, model.DB.Create(&b).Error)
|
||||
return b
|
||||
}
|
||||
withSecretA := mk(7777, "azkv://v.vault.azure.net/secrets/users-7777-a")
|
||||
withSecretB := mk(7777, "azkv://v.vault.azure.net/secrets/users-7777-b")
|
||||
noSecret := mk(7777, "") // not selected (secret_ref empty) -> stays active
|
||||
otherUser := mk(8888, "azkv://v.vault.azure.net/secrets/users-8888-a")
|
||||
|
||||
revokeUserResourceSecrets(7777)
|
||||
|
||||
get := func(id int) string {
|
||||
var b model.ResourceBinding
|
||||
require.NoError(t, model.DB.Where("id = ?", id).First(&b).Error)
|
||||
return b.Status
|
||||
}
|
||||
require.Equal(t, "revoked", get(withSecretA.Id))
|
||||
require.Equal(t, "revoked", get(withSecretB.Id))
|
||||
require.Equal(t, "active", get(noSecret.Id), "secret-less binding must not be revoked")
|
||||
require.Equal(t, "active", get(otherUser.Id), "another user's binding must be untouched")
|
||||
}
|
||||
@@ -140,10 +140,6 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "购买上限不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.MaxAgents < 0 {
|
||||
common.ApiErrorMsg(c, "Agent 部署上限不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.TotalAmount < 0 {
|
||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||
return
|
||||
@@ -207,10 +203,6 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "购买上限不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.MaxAgents < 0 {
|
||||
common.ApiErrorMsg(c, "Agent 部署上限不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.TotalAmount < 0 {
|
||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||
return
|
||||
@@ -243,7 +235,6 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
"stripe_price_id": req.Plan.StripePriceId,
|
||||
"creem_product_id": req.Plan.CreemProductId,
|
||||
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
|
||||
"max_agents": req.Plan.MaxAgents,
|
||||
"total_amount": req.Plan.TotalAmount,
|
||||
"upgrade_group": req.Plan.UpgradeGroup,
|
||||
"quota_reset_period": req.Plan.QuotaResetPeriod,
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/heicode/manager/model"
|
||||
)
|
||||
|
||||
// Telemetry retention (#32). Account-linkable client error-telemetry must not be
|
||||
// kept indefinitely: a daily task deletes rows older than the retention window.
|
||||
// Disabled when telemetry ingest is off (default) or retention <= 0.
|
||||
//
|
||||
// - HEICODE_TELEMETRY_RETENTION_DAYS (default 30): rows received earlier than
|
||||
// now-RETENTION are deleted. <=0 disables the purge (keep-forever — only for
|
||||
// explicit operator opt-out; not recommended for production).
|
||||
// - HEICODE_TELEMETRY_RETENTION_INTERVAL_HOURS (default 24): sweep cadence.
|
||||
//
|
||||
// Master-only (wired from main.go under IsMasterNode) so multiple nodes don't
|
||||
// all sweep the shared LOG_DB.
|
||||
|
||||
var telemetryRetentionTaskOnce sync.Once
|
||||
|
||||
func telemetryRetentionDays() int {
|
||||
return common.GetEnvOrDefault("HEICODE_TELEMETRY_RETENTION_DAYS", 30)
|
||||
}
|
||||
|
||||
// StartTelemetryRetentionTask launches the daily telemetry retention sweep.
|
||||
func StartTelemetryRetentionTask() {
|
||||
telemetryRetentionTaskOnce.Do(func() {
|
||||
// Only meaningful once ingest is enabled; if the endpoint is off there is
|
||||
// nothing being written, but we still allow the sweep to drain any rows
|
||||
// captured during a prior enabled window. Gate on retention days instead.
|
||||
days := telemetryRetentionDays()
|
||||
if days <= 0 {
|
||||
common.SysLog("telemetry retention task disabled (HEICODE_TELEMETRY_RETENTION_DAYS<=0; rows kept indefinitely)")
|
||||
return
|
||||
}
|
||||
intervalHours := common.GetEnvOrDefault("HEICODE_TELEMETRY_RETENTION_INTERVAL_HOURS", 24)
|
||||
if intervalHours < 1 {
|
||||
intervalHours = 24
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(5 * time.Minute) // avoid startup churn
|
||||
runTelemetryRetentionOnce()
|
||||
ticker := time.NewTicker(time.Duration(intervalHours) * time.Hour)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
runTelemetryRetentionOnce()
|
||||
}
|
||||
}()
|
||||
common.SysLog(fmt.Sprintf("telemetry retention task started: retention=%dd interval=%dh", days, intervalHours))
|
||||
})
|
||||
}
|
||||
|
||||
func runTelemetryRetentionOnce() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
common.SysLog(fmt.Sprintf("telemetry retention task panic recovered: %v", r))
|
||||
}
|
||||
}()
|
||||
days := telemetryRetentionDays()
|
||||
if days <= 0 {
|
||||
return
|
||||
}
|
||||
cutoff := time.Now().Unix() - int64(days)*86400
|
||||
deleted, err := model.DeleteTelemetryEventsBefore(cutoff)
|
||||
if err != nil {
|
||||
common.SysLog("telemetry retention task: " + err.Error())
|
||||
return
|
||||
}
|
||||
if deleted > 0 {
|
||||
common.SysLog(fmt.Sprintf("telemetry retention task: deleted %d events older than %d days", deleted, days))
|
||||
}
|
||||
}
|
||||
@@ -780,9 +780,6 @@ func DeleteUser(c *gin.Context) {
|
||||
})
|
||||
return
|
||||
}
|
||||
// Hard-deleting an account also revokes + deletes its resource-bound Key
|
||||
// Vault credential material (issue #4). Best-effort.
|
||||
revokeUserResourceSecrets(id)
|
||||
}
|
||||
|
||||
func DeleteSelf(c *gin.Context) {
|
||||
@@ -799,9 +796,6 @@ func DeleteSelf(c *gin.Context) {
|
||||
common.ApiError(c, err)
|
||||
return
|
||||
}
|
||||
// Self-deletion also revokes + deletes the account's resource-bound Key
|
||||
// Vault credential material (issue #4). Best-effort.
|
||||
revokeUserResourceSecrets(id)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"message": "",
|
||||
@@ -904,10 +898,6 @@ func ManageUser(c *gin.Context) {
|
||||
if err := model.InvalidateUserTokensCache(user.Id); err != nil {
|
||||
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
|
||||
}
|
||||
// Account deletion must also revoke + delete the user's resource-bound
|
||||
// Key Vault credential material (issue #4): account gone, secrets gone.
|
||||
// Best-effort; never blocks the delete.
|
||||
revokeUserResourceSecrets(user.Id)
|
||||
case "promote":
|
||||
if myRole != common.RoleRootUser {
|
||||
common.ApiErrorI18n(c, i18n.MsgUserAdminCannotPromote)
|
||||
@@ -993,11 +983,6 @@ func ManageUser(c *gin.Context) {
|
||||
common.SysLog(fmt.Sprintf("failed to invalidate tokens cache for user %d: %s", user.Id, err.Error()))
|
||||
}
|
||||
}
|
||||
if req.Action == "disable" {
|
||||
// Disabling an account also revokes its resource-bound KV secrets (issue
|
||||
// #4), so a disabled user's live credentials don't linger in the vault.
|
||||
revokeUserResourceSecrets(user.Id)
|
||||
}
|
||||
clearUser := model.User{
|
||||
Role: user.Role,
|
||||
Status: user.Status,
|
||||
|
||||
@@ -61,10 +61,7 @@ services:
|
||||
- AGENT_RUNTIME_STOP_PATH=${AGENT_RUNTIME_STOP_PATH:-/api/agent/deployments/{deployment_id}/stop}
|
||||
- AGENT_RUNTIME_SERVICE_TOKEN=${AGENT_RUNTIME_SERVICE_TOKEN:-}
|
||||
- AGENT_RUNTIME_CALLBACK_SIGNING_SECRET_REF=${AGENT_RUNTIME_CALLBACK_SIGNING_SECRET_REF:-}
|
||||
# agent_swarm (product: HeiCode Swarm) Runtime — separate from the single
|
||||
# template-agent Runtime (AM). Currently DEFERRED/disabled (SWARM_RUNTIME_ENABLED=false):
|
||||
# HM does not orchestrate swarm; pending agent_swarm runtime-contract.md freeze (agent_swarm#2).
|
||||
# See docs/integration/heicode-swarm-deferred.md.
|
||||
# HeiCode-Swarm Runtime is separate from ordinary sub Agent Runtime.
|
||||
- SWARM_RUNTIME_ENABLED=${SWARM_RUNTIME_ENABLED:-false}
|
||||
- SWARM_RUNTIME_BASE_URL=${SWARM_RUNTIME_BASE_URL:-}
|
||||
- SWARM_RUNTIME_CREATE_PATH=${SWARM_RUNTIME_CREATE_PATH:-/api/swarms}
|
||||
|
||||
@@ -130,15 +130,6 @@ func main() {
|
||||
// Channel upstream model update check task
|
||||
controller.StartChannelUpstreamModelUpdateTask()
|
||||
|
||||
// Secret lifecycle: daily purge of vault secrets soft-deleted past the
|
||||
// retention window (issue #4). Master-only so multiple nodes don't all purge.
|
||||
if common.IsMasterNode {
|
||||
controller.StartSecretPurgeTask()
|
||||
// Telemetry retention: daily purge of client error-telemetry older than
|
||||
// HEICODE_TELEMETRY_RETENTION_DAYS (#32). Master-only.
|
||||
controller.StartTelemetryRetentionTask()
|
||||
}
|
||||
|
||||
if common.IsMasterNode && constant.UpdateTask {
|
||||
gopool.Go(func() {
|
||||
controller.UpdateMidjourneyTaskBulk()
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
@@ -76,56 +75,11 @@ func InsertAgentAuditEvent(evt *AgentAuditEvent) {
|
||||
if evt.Result == "" {
|
||||
evt.Result = "ok"
|
||||
}
|
||||
// Defence-in-depth: scrub plaintext secrets out of the free-form payload
|
||||
// before it lands in the audit table. DetailsJSON is the catch-all bucket
|
||||
// (failure_reason / phase / callback_url …) and a callback_url can carry a
|
||||
// query-string token; product policy forbids plaintext token/password/
|
||||
// private-key/access-key in logs. Callers should still avoid putting secrets
|
||||
// here — this is a backstop, not a licence to log them.
|
||||
evt.DetailsJSON = redactAuditSecrets(evt.DetailsJSON)
|
||||
if err := DB.Create(evt).Error; err != nil {
|
||||
common.SysLog("InsertAgentAuditEvent: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// secretRedactors strip well-known secret shapes from free-form audit payloads.
|
||||
// Targeted (not a generic high-entropy scan) to avoid mangling normal text;
|
||||
// over-redacting a rare false positive in audit details is preferable to
|
||||
// leaking a credential.
|
||||
var secretRedactors = []struct {
|
||||
re *regexp.Regexp
|
||||
repl string
|
||||
}{
|
||||
// sk- style API keys (NewAPI / OpenAI-compatible gateway keys)
|
||||
{regexp.MustCompile(`sk-[A-Za-z0-9_-]{8,}`), "sk-***REDACTED***"},
|
||||
// Authorization: Bearer <token>
|
||||
{regexp.MustCompile(`(?i)(bearer\s+)[A-Za-z0-9._\-]{8,}`), "${1}***REDACTED***"},
|
||||
// URL / form query secrets: ?token=.. &access_key=.. (keep the key, drop value)
|
||||
{regexp.MustCompile(`(?i)([?&](?:access_token|refresh_token|token|api_?key|access_?key|secret|password|passwd|pwd|sig|signature)=)[^&\s"']+`), "${1}***REDACTED***"},
|
||||
// JSON string fields: "password":"..", "token":"..", "access_key":".."
|
||||
{regexp.MustCompile(`(?i)("(?:password|passwd|pwd|secret|api_?key|access_?key|private_?key|token|refresh_token)"\s*:\s*")[^"]*(")`), "${1}***REDACTED***${2}"},
|
||||
}
|
||||
|
||||
// redactAuditSecrets removes plaintext secrets from a free-form audit payload
|
||||
// before persistence. Returns the input unchanged when empty.
|
||||
func redactAuditSecrets(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
for _, r := range secretRedactors {
|
||||
s = r.re.ReplaceAllString(s, r.repl)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// RedactText is an exported wrapper over the audit secret-redactor so other
|
||||
// ingest paths (e.g. client telemetry #24) can defense-in-depth strip plaintext
|
||||
// secrets (sk-/Bearer/URL tokens/JSON secret fields) before persistence, even
|
||||
// when the producer claims the payload is already sanitized.
|
||||
func RedactText(s string) string {
|
||||
return redactAuditSecrets(s)
|
||||
}
|
||||
|
||||
// ListAgentAuditEventsFilter narrows the audit query to a slice of
|
||||
// dashboard relevant rows. Zero-value fields are ignored — callers
|
||||
// pass only the filters they care about.
|
||||
|
||||
@@ -2,7 +2,6 @@ package model
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -142,94 +141,3 @@ func TestListAgentAuditEventsByDeployment_Chronological(t *testing.T) {
|
||||
t.Fatalf("expected [evt_early, evt_late] order, got %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRedactAuditSecrets locks the audit-log redaction security boundary:
|
||||
// plaintext secrets must never reach the audit table's free-form DetailsJSON.
|
||||
// Covers every shape secretRedactors targets (sk-, Bearer, URL query token,
|
||||
// JSON password/api_key/private_key) and guards against over-redacting normal
|
||||
// text. This is a security boundary, so it ships as a persistent unit test
|
||||
// rather than a one-off smoke check (see #9).
|
||||
func TestRedactAuditSecrets(t *testing.T) {
|
||||
const redacted = "***REDACTED***"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
mustNotHave string // a secret value that must be gone after redaction
|
||||
mustHave []string // substrings that must survive (keys kept, normal text intact)
|
||||
}{
|
||||
{
|
||||
name: "sk- gateway key",
|
||||
in: `{"failure_reason":"upstream rejected key sk-abcDEF1234567890"}`,
|
||||
mustNotHave: "sk-abcDEF1234567890",
|
||||
mustHave: []string{"sk-" + redacted, `"failure_reason"`},
|
||||
},
|
||||
{
|
||||
name: "Authorization Bearer token",
|
||||
in: `called callback with header Authorization: Bearer aZ09._-tokenValue123`,
|
||||
mustNotHave: "aZ09._-tokenValue123",
|
||||
mustHave: []string{redacted, "Authorization"},
|
||||
},
|
||||
{
|
||||
name: "URL query token keeps key drops value",
|
||||
in: `callback_url=https://hook.example.com/cb?token=supersecretval123&phase=deploy`,
|
||||
mustNotHave: "supersecretval123",
|
||||
mustHave: []string{"token=" + redacted, "phase=deploy"}, // non-secret param survives
|
||||
},
|
||||
{
|
||||
name: "URL query access_key",
|
||||
in: `https://x/y?access_key=AKIA1234567890abcd®ion=eastus`,
|
||||
mustNotHave: "AKIA1234567890abcd",
|
||||
mustHave: []string{"access_key=" + redacted, "region=eastus"},
|
||||
},
|
||||
{
|
||||
name: "JSON password field",
|
||||
in: `{"db":"pg","password":"hunter2pass","host":"db.local"}`,
|
||||
mustNotHave: "hunter2pass",
|
||||
mustHave: []string{`"password":"` + redacted + `"`, `"host":"db.local"`},
|
||||
},
|
||||
{
|
||||
name: "JSON api_key value",
|
||||
in: `{"api_key":"k-LIVE-9988","note":"ok"}`,
|
||||
mustNotHave: "k-LIVE-9988",
|
||||
mustHave: []string{redacted, `"note":"ok"`},
|
||||
},
|
||||
{
|
||||
name: "JSON private_key value",
|
||||
in: `{"private_key":"MIIEvQIBADANBgkq"}`,
|
||||
mustNotHave: "MIIEvQIBADANBgkq",
|
||||
mustHave: []string{`"private_key":"` + redacted + `"`},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := redactAuditSecrets(tc.in)
|
||||
if tc.mustNotHave != "" && strings.Contains(got, tc.mustNotHave) {
|
||||
t.Fatalf("secret leaked: %q still present in %q", tc.mustNotHave, got)
|
||||
}
|
||||
for _, want := range tc.mustHave {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("expected %q to survive, got %q", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Guard: normal audit text with no secret shape must pass through unchanged —
|
||||
// over-redaction would corrupt legitimate audit context.
|
||||
for _, s := range []string{
|
||||
"deploy failed: AM returned HTTP 500, pod not ready",
|
||||
"phase=provisioning binding_scope=git-master status=running",
|
||||
"agent dep_abc123 stopped by user",
|
||||
} {
|
||||
if got := redactAuditSecrets(s); got != s {
|
||||
t.Fatalf("normal text over-redacted: %q -> %q", s, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty input is returned unchanged.
|
||||
if got := redactAuditSecrets(""); got != "" {
|
||||
t.Fatalf("empty input changed to %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
)
|
||||
import "errors"
|
||||
|
||||
type AgentCallbackEvent struct {
|
||||
Id int `gorm:"primaryKey" json:"id"`
|
||||
@@ -18,9 +15,6 @@ type AgentCallbackEvent struct {
|
||||
UserID string `gorm:"type:varchar(64);index" json:"user_id"`
|
||||
BindingScope string `gorm:"type:varchar(512);index" json:"binding_scope"`
|
||||
CorrelationID string `gorm:"type:varchar(64);index" json:"correlation_id"`
|
||||
// agent_swarm event-schema v1(#15):per-swarm 严格递增序号(每 swarm 从 1、无空洞)。
|
||||
// 0 = envelope 未带(legacy / 非 swarm);对外作客户端续传/去重游标。
|
||||
Sequence int `gorm:"index;default:0" json:"sequence"`
|
||||
Source string `gorm:"type:varchar(64)" json:"source"`
|
||||
Result string `gorm:"type:varchar(32)" json:"result"`
|
||||
PayloadJSON string `gorm:"type:text" json:"payload_json"`
|
||||
@@ -61,41 +55,6 @@ func InsertAgentCallbackEvent(row *AgentCallbackEvent) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ListSwarmCallbackEventsAfter returns persisted swarm runtime events for one run
|
||||
// (matched by deployment_id OR swarm_id) with an id-based `after` cursor for
|
||||
// incremental polling (#45 events?after). Ordered oldest-first so the client can
|
||||
// append; the caller uses the last returned Id as the next `after`. Reading from
|
||||
// HM-persisted callback rows means this needs no live Swarm call.
|
||||
func ListSwarmCallbackEventsAfter(userID, deploymentID, swarmID string, afterID, limit int) ([]AgentCallbackEvent, error) {
|
||||
if DB == nil {
|
||||
return nil, nil
|
||||
}
|
||||
q := DB.Model(&AgentCallbackEvent{})
|
||||
switch {
|
||||
case deploymentID != "" && swarmID != "":
|
||||
q = q.Where("deployment_id = ? OR swarm_id = ?", deploymentID, swarmID)
|
||||
case deploymentID != "":
|
||||
q = q.Where("deployment_id = ?", deploymentID)
|
||||
case swarmID != "":
|
||||
q = q.Where("swarm_id = ?", swarmID)
|
||||
default:
|
||||
return nil, nil
|
||||
}
|
||||
// 防跨用户泄漏:即使 runtime_swarm_id/deployment_id 碰撞或误写,也按 user_id 收口(#45 复审 #3)。
|
||||
if strings.TrimSpace(userID) != "" {
|
||||
q = q.Where("user_id = ?", userID)
|
||||
}
|
||||
if afterID > 0 {
|
||||
q = q.Where("id > ?", afterID)
|
||||
}
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
var items []AgentCallbackEvent
|
||||
err := q.Order("id asc").Limit(limit).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func ListAgentCallbackEvents(f ListAgentCallbackEventsFilter) ([]AgentCallbackEvent, error) {
|
||||
if DB == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// #45: events?after 游标 + user 作用域 —— 按 deployment_id/swarm_id 过滤,id>after 增量,oldest-first;
|
||||
// 传入 userID 时按 user_id 收口(防跨用户泄漏,复审 #3)。
|
||||
func TestListSwarmCallbackEventsAfter(t *testing.T) {
|
||||
require.NoError(t, LOG_DB.Where("1 = 1").Delete(&AgentCallbackEvent{}).Error)
|
||||
mk := func(eventID, uid, dep, swarm, etype string) {
|
||||
_, err := InsertAgentCallbackEvent(&AgentCallbackEvent{
|
||||
EventID: eventID, UserID: uid, DeploymentID: dep, SwarmID: swarm, EventType: etype,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
mk("e1", "7", "dep_A", "swarm_A", "deployment.status_changed")
|
||||
mk("e2", "7", "dep_A", "swarm_A", "task.created")
|
||||
mk("e3", "7", "dep_A", "swarm_A", "artifact.produced")
|
||||
mk("e4", "7", "dep_B", "swarm_B", "task.created") // 另一个 run
|
||||
mk("e5", "9", "dep_A", "swarm_A", "task.created") // 同 dep/swarm 但别的用户 → 不应泄漏给 user 7
|
||||
|
||||
// user 7 + dep_A:3 条(e5 属 user 9,被排除),oldest-first
|
||||
all, err := ListSwarmCallbackEventsAfter("7", "dep_A", "swarm_A", 0, 100)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, all, 3)
|
||||
require.Equal(t, "e1", all[0].EventID)
|
||||
for _, e := range all {
|
||||
require.NotEqual(t, "e5", e.EventID, "不得返回别的用户的事件")
|
||||
}
|
||||
|
||||
// 游标:after = 第一条 id → 其后 2 条
|
||||
rest, err := ListSwarmCallbackEventsAfter("7", "dep_A", "swarm_A", all[0].Id, 100)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rest, 2)
|
||||
require.Equal(t, "e2", rest[0].EventID)
|
||||
|
||||
// user 9 只看到自己的 e5
|
||||
u9, err := ListSwarmCallbackEventsAfter("9", "dep_A", "swarm_A", 0, 100)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, u9, 1)
|
||||
require.Equal(t, "e5", u9[0].EventID)
|
||||
|
||||
// 空标识 → 空
|
||||
none, err := ListSwarmCallbackEventsAfter("7", "", "", 0, 100)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, none)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// SumAgentUsage rolls up a single agent token's consume logs (issue #9). logs is
|
||||
// migrated by the package TestMain; LOG_DB == DB in tests.
|
||||
func TestSumAgentUsage(t *testing.T) {
|
||||
tok := "agent:dep_usage_test"
|
||||
mk := func(uid int, tname string, typ, quota, pt, ct int) {
|
||||
require.NoError(t, LOG_DB.Create(&Log{
|
||||
UserId: uid, TokenName: tname, Type: typ,
|
||||
Quota: quota, PromptTokens: pt, CompletionTokens: ct, CreatedAt: 1700000000,
|
||||
}).Error)
|
||||
}
|
||||
mk(970001, tok, LogTypeConsume, 100, 10, 5)
|
||||
mk(970001, tok, LogTypeConsume, 200, 20, 15)
|
||||
mk(970001, "agent:other", LogTypeConsume, 999, 99, 99) // different token — excluded
|
||||
mk(970001, tok, LogTypeManage, 500, 0, 0) // non-consume — excluded
|
||||
mk(970002, tok, LogTypeConsume, 777, 7, 7) // different user — excluded by user filter
|
||||
|
||||
u, err := SumAgentUsage(970001, tok, 0, 0)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 300, u.Quota)
|
||||
require.EqualValues(t, 30, u.PromptTokens)
|
||||
require.EqualValues(t, 20, u.CompletionTokens)
|
||||
require.EqualValues(t, 2, u.CallCount)
|
||||
|
||||
// time window excludes the rows (all stamped at 1700000000)
|
||||
windowed, err := SumAgentUsage(970001, tok, 1800000000, 1900000000)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, windowed.CallCount)
|
||||
|
||||
// empty token name -> zero, no error
|
||||
z, err := SumAgentUsage(970001, "", 0, 0)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, z.CallCount)
|
||||
}
|
||||
@@ -432,41 +432,6 @@ type Stat struct {
|
||||
Tpm int `json:"tpm"`
|
||||
}
|
||||
|
||||
// AgentUsage is the per-deployed-agent usage rollup (issue #9): a template agent
|
||||
// calls HM /v1/* with its own minted token named "agent:<deployment_id>", so its
|
||||
// consumption is the sum of consume logs under that token name.
|
||||
type AgentUsage struct {
|
||||
Quota int64 `json:"quota"`
|
||||
PromptTokens int64 `json:"prompt_tokens"`
|
||||
CompletionTokens int64 `json:"completion_tokens"`
|
||||
CallCount int64 `json:"call_count"`
|
||||
}
|
||||
|
||||
// SumAgentUsage aggregates consume-log usage for a single agent token (and user,
|
||||
// when userId>0) over an optional time window. COALESCE keeps SUM non-null on
|
||||
// empty sets; COUNT/SUM are portable across SQLite/MySQL/PostgreSQL.
|
||||
func SumAgentUsage(userId int, tokenName string, startTimestamp int64, endTimestamp int64) (AgentUsage, error) {
|
||||
var u AgentUsage
|
||||
if tokenName == "" {
|
||||
return u, nil
|
||||
}
|
||||
tx := LOG_DB.Table("logs").
|
||||
Select("COALESCE(SUM(quota),0) AS quota, COALESCE(SUM(prompt_tokens),0) AS prompt_tokens, COALESCE(SUM(completion_tokens),0) AS completion_tokens, COUNT(*) AS call_count").
|
||||
Where("type = ?", LogTypeConsume).
|
||||
Where("token_name = ?", tokenName)
|
||||
if userId > 0 {
|
||||
tx = tx.Where("user_id = ?", userId)
|
||||
}
|
||||
if startTimestamp != 0 {
|
||||
tx = tx.Where("created_at >= ?", startTimestamp)
|
||||
}
|
||||
if endTimestamp != 0 {
|
||||
tx = tx.Where("created_at <= ?", endTimestamp)
|
||||
}
|
||||
err := tx.Scan(&u).Error
|
||||
return u, err
|
||||
}
|
||||
|
||||
func SumUsedQuota(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, channel int, group string) (stat Stat, err error) {
|
||||
tx := LOG_DB.Table("logs").Select("sum(quota) quota")
|
||||
|
||||
|
||||
@@ -333,8 +333,6 @@ func migrateDB() error {
|
||||
// in-process `agentEvents map` that was wiped on every container
|
||||
// restart. See model/agent_audit.go for the rationale.
|
||||
&AgentAuditEvent{},
|
||||
&TelemetryEvent{},
|
||||
&PreflightConfirmation{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PreflightConfirmation 持久化一次 preflight「已确认版本」记录(#41 复审 #1/#4)。
|
||||
//
|
||||
// 为什么需要它:仅靠「部署时重算 hash == 传入 version」无法证明这个 version 曾被 confirm ——
|
||||
// GET /api/heicode/preflight 也会返回同一个 version,客户端可绕过 confirm 与审计直接拿 GET
|
||||
// version 去部署。把 confirm 落成一条**强一致、可查询**的记录,部署时校验该记录确实存在且未过期,
|
||||
// 才真正满足「启动接口校验已确认版本」。审计事件(agent_audit_events)作为附加审计流。
|
||||
type PreflightConfirmation struct {
|
||||
Id int `gorm:"primaryKey" json:"id"`
|
||||
UserID int `gorm:"index" json:"user_id"`
|
||||
TemplateID string `gorm:"type:varchar(64);index" json:"template_id"`
|
||||
BindingKey string `gorm:"type:varchar(512);index" json:"binding_key"` // 归一化排序后的 binding ids,便于审计/排查
|
||||
Version string `gorm:"type:varchar(64);index" json:"version"` // 防篡改摘要版本
|
||||
CreatedAtMs int64 `gorm:"bigint;index" json:"created_at_ms"`
|
||||
ExpiresAtMs int64 `gorm:"bigint;index" json:"expires_at_ms"` // 0 表示不过期
|
||||
}
|
||||
|
||||
func (PreflightConfirmation) TableName() string { return "preflight_confirmations" }
|
||||
|
||||
// PreflightBindingKey 把绑定 id 归一化(去重/去非正/升序)后拼成稳定 key,confirm 与 deploy
|
||||
// 用同一算法,保证同一组绑定得到同一 key。
|
||||
func PreflightBindingKey(bindingIDs []int) string {
|
||||
seen := map[int]bool{}
|
||||
ids := make([]int, 0, len(bindingIDs))
|
||||
for _, n := range bindingIDs {
|
||||
if n > 0 && !seen[n] {
|
||||
seen[n] = true
|
||||
ids = append(ids, n)
|
||||
}
|
||||
}
|
||||
sort.Ints(ids)
|
||||
parts := make([]string, 0, len(ids))
|
||||
for _, n := range ids {
|
||||
parts = append(parts, strconv.Itoa(n))
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// InsertPreflightConfirmation 持久化一条确认记录。
|
||||
func InsertPreflightConfirmation(rec *PreflightConfirmation) error {
|
||||
if DB == nil || rec == nil {
|
||||
return nil
|
||||
}
|
||||
return DB.Create(rec).Error
|
||||
}
|
||||
|
||||
// PreflightConfirmationExists 校验存在一条匹配的、未过期的确认记录(#41 部署侧强校验)。
|
||||
// 匹配 user + template + version(version 已编码资源+模板安全面);nowMs 用于过期判断,便于单测。
|
||||
func PreflightConfirmationExists(userID int, templateID, version string, nowMs int64) (bool, error) {
|
||||
if DB == nil {
|
||||
return false, nil
|
||||
}
|
||||
version = strings.TrimSpace(version)
|
||||
if userID <= 0 || strings.TrimSpace(templateID) == "" || version == "" {
|
||||
return false, nil
|
||||
}
|
||||
var count int64
|
||||
err := DB.Model(&PreflightConfirmation{}).
|
||||
Where("user_id = ? AND template_id = ? AND version = ?", userID, templateID, version).
|
||||
Where("expires_at_ms = 0 OR expires_at_ms > ?", nowMs).
|
||||
Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// #41 复审 #1/#4:确认记录强校验 —— 存在/版本不符/过期/跨用户。
|
||||
func TestPreflightConfirmationExists(t *testing.T) {
|
||||
require.NoError(t, DB.Where("1 = 1").Delete(&PreflightConfirmation{}).Error)
|
||||
const now = int64(1_700_000_000_000)
|
||||
|
||||
require.NoError(t, InsertPreflightConfirmation(&PreflightConfirmation{
|
||||
UserID: 7, TemplateID: "architect", BindingKey: "1,2", Version: "pfv1_abc",
|
||||
CreatedAtMs: now, ExpiresAtMs: now + 3600_000,
|
||||
}))
|
||||
|
||||
// 命中
|
||||
ok, err := PreflightConfirmationExists(7, "architect", "pfv1_abc", now+1000)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
// 版本不符 → 不命中(防止拿别的 version 部署)
|
||||
ok, _ = PreflightConfirmationExists(7, "architect", "pfv1_OTHER", now+1000)
|
||||
require.False(t, ok)
|
||||
|
||||
// 跨用户 → 不命中
|
||||
ok, _ = PreflightConfirmationExists(9, "architect", "pfv1_abc", now+1000)
|
||||
require.False(t, ok)
|
||||
|
||||
// 过期 → 不命中
|
||||
ok, _ = PreflightConfirmationExists(7, "architect", "pfv1_abc", now+7200_000)
|
||||
require.False(t, ok)
|
||||
|
||||
// 空参数 → 不命中
|
||||
ok, _ = PreflightConfirmationExists(0, "architect", "pfv1_abc", now)
|
||||
require.False(t, ok)
|
||||
ok, _ = PreflightConfirmationExists(7, "architect", "", now)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// 不过期记录(ExpiresAtMs=0)恒命中。
|
||||
func TestPreflightConfirmationExists_NoExpiry(t *testing.T) {
|
||||
require.NoError(t, DB.Where("1 = 1").Delete(&PreflightConfirmation{}).Error)
|
||||
require.NoError(t, InsertPreflightConfirmation(&PreflightConfirmation{
|
||||
UserID: 5, TemplateID: "t", Version: "v", CreatedAtMs: 1, ExpiresAtMs: 0,
|
||||
}))
|
||||
ok, err := PreflightConfirmationExists(5, "t", "v", 9_999_999_999_999)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
}
|
||||
|
||||
func TestPreflightBindingKey_Normalizes(t *testing.T) {
|
||||
require.Equal(t, "1,2,3", PreflightBindingKey([]int{3, 1, 2, 3, 0, -1, 1}))
|
||||
require.Equal(t, "", PreflightBindingKey(nil))
|
||||
}
|
||||
@@ -165,12 +165,6 @@ type SubscriptionPlan struct {
|
||||
// Max purchases per user (0 = unlimited)
|
||||
MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"`
|
||||
|
||||
// Max concurrently-deployed template agents for users on this plan
|
||||
// (0 = fall back to the global HEICODE_MAX_AGENTS_PER_USER default).
|
||||
// Enforced in HeicodeDeployAgent. This is how 个人 5 / 团队 8 (#8) is
|
||||
// expressed: admins set each plan's cap; the code does not hard-code tiers.
|
||||
MaxAgents int `json:"max_agents" gorm:"type:int;default:0"`
|
||||
|
||||
// Upgrade user group after purchase (empty = no change)
|
||||
UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"`
|
||||
|
||||
@@ -702,36 +696,6 @@ func HasActiveUserSubscription(userId int) (bool, error) {
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetUserMaxAgents returns the user's effective concurrent-agent cap: the highest
|
||||
// MaxAgents among their active subscription plans, or defaultMax when no active
|
||||
// plan sets one. This is the tier-aware enforcement for #8 (个人默认 / 团队档),
|
||||
// keyed off admin-configured per-plan caps rather than a hard-coded tier guess.
|
||||
func GetUserMaxAgents(userId int, defaultMax int) int {
|
||||
if userId <= 0 || DB == nil {
|
||||
return defaultMax
|
||||
}
|
||||
now := common.GetTimestamp()
|
||||
var subs []UserSubscription
|
||||
if err := DB.Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now).
|
||||
Find(&subs).Error; err != nil || len(subs) == 0 {
|
||||
return defaultMax
|
||||
}
|
||||
best := 0
|
||||
for _, s := range subs {
|
||||
plan, err := GetSubscriptionPlanById(s.PlanId)
|
||||
if err != nil || plan == nil {
|
||||
continue
|
||||
}
|
||||
if plan.MaxAgents > best {
|
||||
best = plan.MaxAgents
|
||||
}
|
||||
}
|
||||
if best > 0 {
|
||||
return best
|
||||
}
|
||||
return defaultMax
|
||||
}
|
||||
|
||||
// GetAllUserSubscriptions returns all subscriptions (active and expired) for a user.
|
||||
func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
|
||||
if userId <= 0 {
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// GetUserMaxAgents drives the tier-aware deploy cap (#8): highest MaxAgents among
|
||||
// the user's ACTIVE subscription plans, else the caller's default.
|
||||
func TestGetUserMaxAgents(t *testing.T) {
|
||||
// SubscriptionPlan / UserSubscription are migrated by the package TestMain
|
||||
// (task_cas_test.go); the table already carries the new max_agents column.
|
||||
now := common.GetTimestamp()
|
||||
future := now + 100000
|
||||
const def = 5
|
||||
|
||||
teamPlan := SubscriptionPlan{Title: "team-cap", MaxAgents: 8}
|
||||
require.NoError(t, DB.Create(&teamPlan).Error)
|
||||
soloPlan := SubscriptionPlan{Title: "solo-cap", MaxAgents: 0} // 0 => fall back to default
|
||||
require.NoError(t, DB.Create(&soloPlan).Error)
|
||||
midPlan := SubscriptionPlan{Title: "mid-cap", MaxAgents: 5}
|
||||
require.NoError(t, DB.Create(&midPlan).Error)
|
||||
|
||||
mkSub := func(uid, planId int, end int64) {
|
||||
require.NoError(t, DB.Create(&UserSubscription{UserId: uid, PlanId: planId, Status: "active", EndTime: end}).Error)
|
||||
}
|
||||
|
||||
// 1. no subscription -> default
|
||||
require.Equal(t, def, GetUserMaxAgents(990001, def))
|
||||
|
||||
// 2. active team plan -> its cap (8)
|
||||
mkSub(990002, teamPlan.Id, future)
|
||||
require.Equal(t, 8, GetUserMaxAgents(990002, def))
|
||||
|
||||
// 3. active plan with MaxAgents=0 -> default
|
||||
mkSub(990003, soloPlan.Id, future)
|
||||
require.Equal(t, def, GetUserMaxAgents(990003, def))
|
||||
|
||||
// 4. expired (end_time in the past) -> default, even if row still says active
|
||||
mkSub(990004, teamPlan.Id, now-100)
|
||||
require.Equal(t, def, GetUserMaxAgents(990004, def))
|
||||
|
||||
// 5. multiple active plans -> highest cap wins (5 and 8 -> 8)
|
||||
mkSub(990005, midPlan.Id, future)
|
||||
mkSub(990005, teamPlan.Id, future)
|
||||
require.Equal(t, 8, GetUserMaxAgents(990005, def))
|
||||
|
||||
// guard: invalid user -> default
|
||||
require.Equal(t, def, GetUserMaxAgents(0, def))
|
||||
}
|
||||
@@ -43,10 +43,6 @@ func TestMain(m *testing.M) {
|
||||
&SubscriptionPlan{},
|
||||
&SubscriptionOrder{},
|
||||
&UserSubscription{},
|
||||
&TelemetryEvent{},
|
||||
&PreflightConfirmation{},
|
||||
&AgentDeployment{},
|
||||
&AgentCallbackEvent{},
|
||||
); err != nil {
|
||||
panic("failed to migrate: " + err.Error())
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package model
|
||||
|
||||
// TelemetryEvent stores client error-telemetry (issue #24). It is deliberately
|
||||
// isolated from billing: ingest never writes a consume Log nor touches
|
||||
// user.Quota. Event payload carries NO user content — only hashes, enums,
|
||||
// counts and sanitized stack frames (the client sanitizes before sending). The
|
||||
// device_id (ClientId) is account-linkable via UserId — this association is the
|
||||
// reason the privacy policy must disclose "device ID (account-linkable)".
|
||||
type TelemetryEvent struct {
|
||||
Id int `json:"id"`
|
||||
ReceivedAt int64 `json:"received_at" gorm:"index"` // server unix seconds
|
||||
UserId int `json:"user_id" gorm:"index"` // device -> account link
|
||||
|
||||
ClientId string `json:"client_id" gorm:"type:varchar(64);index"` // device_id
|
||||
SchemaVersion int `json:"schema_version" gorm:"default:1"`
|
||||
|
||||
AppVersion string `json:"app_version" gorm:"type:varchar(64);default:''"`
|
||||
Platform string `json:"platform" gorm:"type:varchar(32);default:''"`
|
||||
OsVersion string `json:"os_version" gorm:"type:varchar(64);default:''"`
|
||||
Arch string `json:"arch" gorm:"type:varchar(32);default:''"`
|
||||
Locale string `json:"locale" gorm:"type:varchar(32);default:''"`
|
||||
|
||||
ErrorCategory string `json:"error_category" gorm:"type:varchar(40);index;default:''"`
|
||||
ErrorCode string `json:"error_code" gorm:"type:varchar(128);default:''"`
|
||||
ErrorMessageHash string `json:"error_message_hash" gorm:"type:varchar(32);default:''"`
|
||||
StackHash string `json:"stack_hash" gorm:"type:varchar(32);default:''"`
|
||||
|
||||
StackTopJSON string `json:"stack_top" gorm:"type:text"` // JSON array, sanitized frames
|
||||
ContextJSON string `json:"context" gorm:"type:text"` // JSON object, non-content
|
||||
EventTimestamp string `json:"timestamp" gorm:"type:varchar(40);default:''"` // client ISO8601
|
||||
SessionSeq int `json:"session_seq" gorm:"default:0"` // process-local
|
||||
}
|
||||
|
||||
// InsertTelemetryEvents batch-inserts ingested telemetry. Uses LOG_DB (the same
|
||||
// store as Log) since this is diagnostic, append-only, non-billing data.
|
||||
func InsertTelemetryEvents(events []TelemetryEvent) error {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
return LOG_DB.Create(&events).Error
|
||||
}
|
||||
|
||||
// DeleteTelemetryEventsBefore removes telemetry rows received before cutoffUnix
|
||||
// (server unix seconds), enforcing the retention window (#32). Returns the
|
||||
// number of rows deleted. Account-linkable device telemetry must not be kept
|
||||
// indefinitely; callers run this from a periodic retention task.
|
||||
func DeleteTelemetryEventsBefore(cutoffUnix int64) (int64, error) {
|
||||
if LOG_DB == nil {
|
||||
return 0, nil
|
||||
}
|
||||
res := LOG_DB.Where("received_at < ?", cutoffUnix).Delete(&TelemetryEvent{})
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// #43: the retention purge must delete telemetry rows received before the
|
||||
// cutoff and leave newer rows intact. TelemetryEvent is migrated by the package
|
||||
// TestMain; LOG_DB == DB in tests.
|
||||
func TestDeleteTelemetryEventsBefore(t *testing.T) {
|
||||
// Isolate from any other test's rows.
|
||||
require.NoError(t, LOG_DB.Where("1 = 1").Delete(&TelemetryEvent{}).Error)
|
||||
|
||||
mk := func(receivedAt int64, client string) {
|
||||
require.NoError(t, LOG_DB.Create(&TelemetryEvent{
|
||||
ReceivedAt: receivedAt, UserId: 1, ClientId: client, SchemaVersion: 1,
|
||||
}).Error)
|
||||
}
|
||||
mk(1000, "old-1") // before cutoff -> deleted
|
||||
mk(2999, "old-2") // before cutoff -> deleted
|
||||
mk(3000, "keep-1") // == cutoff -> kept (strict <)
|
||||
mk(5000, "keep-2") // after cutoff -> kept
|
||||
|
||||
deleted, err := DeleteTelemetryEventsBefore(3000)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 2, deleted, "rows with received_at < cutoff are purged")
|
||||
|
||||
var remaining int64
|
||||
require.NoError(t, LOG_DB.Model(&TelemetryEvent{}).Count(&remaining).Error)
|
||||
require.EqualValues(t, 2, remaining, "rows at/after cutoff are retained")
|
||||
|
||||
// Idempotent: a second pass with the same cutoff deletes nothing.
|
||||
again, err := DeleteTelemetryEventsBefore(3000)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, 0, again)
|
||||
}
|
||||
@@ -63,9 +63,6 @@ func SetApiRouter(router *gin.Engine) {
|
||||
apiRouter.POST("/agent/callbacks/runtime-events", controller.AgentReceiveRuntimeEventCallback)
|
||||
// Client-facing capability discovery (unified spec §6). Catalog data only.
|
||||
apiRouter.GET("/heicode/capabilities", controller.HeicodeCapabilities)
|
||||
// Client runtime config (#24): telemetry enable/endpoint/kill-switch etc.
|
||||
// Pollable within a session so kill switches propagate without re-login.
|
||||
apiRouter.GET("/heicode/config", controller.HeicodeConfig)
|
||||
apiRouter.POST("/swarms", middleware.UserOrV2DeviceAuth(), controller.AgentCreateUserSwarm)
|
||||
//apiRouter.POST("/waffo-pancake/webhook", controller.WaffoPancakeWebhook)
|
||||
|
||||
@@ -225,9 +222,6 @@ func SetApiRouter(router *gin.Engine) {
|
||||
resourceRoute.POST("/", controller.CreateResource)
|
||||
resourceRoute.POST("/:id/secret", controller.UpsertResourceSecret)
|
||||
resourceRoute.POST("/:id/azure/discover", controller.DiscoverAzureResources)
|
||||
// Provider-agnostic cloud discovery (#5): routes by the account's provider
|
||||
// (azure/aws/gcp). The azure-specific route above is kept for back-compat.
|
||||
resourceRoute.POST("/:id/discover-cloud", controller.DiscoverCloudResources)
|
||||
resourceRoute.PUT("/:id", controller.UpdateResource)
|
||||
resourceRoute.DELETE("/:id", controller.DeleteResource)
|
||||
}
|
||||
@@ -537,29 +531,12 @@ func SetApiRouter(router *gin.Engine) {
|
||||
heicodeAgentRoute.Use(middleware.UserOrV2DeviceAuth())
|
||||
{
|
||||
heicodeAgentRoute.GET("/agent-templates", controller.HeicodeListAgentTemplates)
|
||||
heicodeAgentRoute.GET("/available-models", controller.HeicodeAvailableModels)
|
||||
// Preflight / execution-summary (#39 缺失项检测 + #40 可读摘要 + #41 confirm/版本).
|
||||
heicodeAgentRoute.GET("/preflight", controller.HeicodePreflight)
|
||||
heicodeAgentRoute.POST("/preflight/confirm", controller.HeicodePreflightConfirm)
|
||||
heicodeAgentRoute.POST("/agents", controller.HeicodeDeployAgent)
|
||||
heicodeAgentRoute.GET("/agents", controller.HeicodeListAgents)
|
||||
heicodeAgentRoute.GET("/agents/:deployment_id", controller.HeicodeGetAgent)
|
||||
heicodeAgentRoute.GET("/agents/:deployment_id/status", controller.HeicodeGetAgentStatus)
|
||||
heicodeAgentRoute.GET("/agents/:deployment_id/usage", controller.HeicodeGetAgentUsage)
|
||||
heicodeAgentRoute.POST("/agents/:deployment_id/stop", controller.HeicodeStopAgent)
|
||||
heicodeAgentRoute.DELETE("/agents/:deployment_id", controller.HeicodeDeleteAgent)
|
||||
// Client error-telemetry ingest (#24). Device-paired; never bills.
|
||||
// Gated by HEICODE_TELEMETRY_ENABLED (default off -> 410 kill switch).
|
||||
heicodeAgentRoute.POST("/telemetry/events", controller.HeicodeTelemetryEvents)
|
||||
// Swarm Run query (#45 Phase1). Read-only views served from HM-persisted
|
||||
// callback data (no live Swarm call); stop calls the runtime (runtime-contract v1,
|
||||
// gated by SWARM_RUNTIME_ENABLED + base_url + service token).
|
||||
heicodeAgentRoute.GET("/swarms", controller.HeicodeListSwarms)
|
||||
heicodeAgentRoute.GET("/swarms/:id", controller.HeicodeGetSwarmStatus)
|
||||
heicodeAgentRoute.GET("/swarms/:id/events", controller.HeicodeListSwarmEvents)
|
||||
heicodeAgentRoute.GET("/swarms/:id/events/stream", controller.HeicodeStreamSwarmEvents)
|
||||
heicodeAgentRoute.GET("/swarms/:id/artifacts", controller.HeicodeListSwarmArtifacts)
|
||||
heicodeAgentRoute.POST("/swarms/:id/stop", controller.HeicodeStopSwarm)
|
||||
}
|
||||
|
||||
// Client↔agent access control (HM-provided, AM-optional). Called by the
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Bot,
|
||||
@@ -112,18 +112,8 @@ export function DeploySubAgentPage() {
|
||||
const agentsQ = useQuery({
|
||||
queryKey: ['heicode-agents'],
|
||||
queryFn: listAgents,
|
||||
refetchInterval: 30_000,
|
||||
retry: false,
|
||||
// Async deploy returns Pending immediately and flips to running seconds later.
|
||||
// Poll fast while anything is still starting, then back off once settled.
|
||||
refetchInterval: (q) => {
|
||||
const items = q.state.data ?? []
|
||||
const settling = items.some((a) =>
|
||||
['pending', 'starting', 'provisioning'].includes(
|
||||
(a.status || '').toLowerCase()
|
||||
)
|
||||
)
|
||||
return settling ? 3_000 : 30_000
|
||||
},
|
||||
})
|
||||
|
||||
const deploy = useMutation({
|
||||
@@ -145,31 +135,6 @@ export function DeploySubAgentPage() {
|
||||
toast.error(e instanceof Error ? e.message : '部署失败,请稍后重试'),
|
||||
})
|
||||
|
||||
// Prominent "done" feedback for async deploy: the POST returns immediately with
|
||||
// Pending; the agent flips to running (or failed) seconds later via the polling
|
||||
// above. Diff each refresh against the previous statuses and toast on the
|
||||
// transition, so the user gets a clear signal instead of silently watching.
|
||||
const prevStatus = useRef<Record<string, string>>({})
|
||||
useEffect(() => {
|
||||
const items = agentsQ.data
|
||||
if (!items) return
|
||||
const prev = prevStatus.current
|
||||
const next: Record<string, string> = {}
|
||||
const starting = ['pending', 'starting', 'provisioning']
|
||||
for (const a of items) {
|
||||
const cur = (a.status || '').toLowerCase()
|
||||
next[a.agent_id] = cur
|
||||
const was = prev[a.agent_id]
|
||||
if (was && was !== cur) {
|
||||
if (starting.includes(was) && ['running', 'active', 'ready'].includes(cur))
|
||||
toast.success('✅ Agent 部署完成,已就绪')
|
||||
else if (['failed', 'error', 'unhealthy', 'crashed'].includes(cur))
|
||||
toast.error('Agent 部署失败,请查看状态')
|
||||
}
|
||||
}
|
||||
prevStatus.current = next
|
||||
}, [agentsQ.data])
|
||||
|
||||
const toggleBinding = (id: number) =>
|
||||
setSelectedBindings((s) =>
|
||||
s.includes(id) ? s.filter((x) => x !== id) : [...s, id]
|
||||
@@ -367,9 +332,6 @@ function AgentRow({
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const st = statusLabel(agent.status)
|
||||
const starting = ['pending', 'starting', 'provisioning'].includes(
|
||||
(agent.status || '').toLowerCase()
|
||||
)
|
||||
|
||||
const stop = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -417,12 +379,10 @@ function AgentRow({
|
||||
<span className='truncate text-sm font-medium'>{templateName}</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ring-inset',
|
||||
st.cls,
|
||||
starting && 'animate-pulse'
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-semibold ring-1 ring-inset',
|
||||
st.cls
|
||||
)}
|
||||
>
|
||||
{starting && <RefreshCw className='h-2.5 w-2.5 animate-spin' />}
|
||||
{st.text}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
-24
@@ -286,30 +286,6 @@ export function SubscriptionsMutateDrawer({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='max_agents'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Agent Deploy Limit')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type='number'
|
||||
min={0}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('0 uses the global default cap')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
|
||||
@@ -21,7 +21,6 @@ export function getPlanFormSchema(t: TFunction) {
|
||||
enabled: z.boolean(),
|
||||
sort_order: z.coerce.number(),
|
||||
max_purchase_per_user: z.coerce.number().min(0),
|
||||
max_agents: z.coerce.number().min(0),
|
||||
total_amount: z.coerce.number().min(0),
|
||||
upgrade_group: z.string().optional(),
|
||||
stripe_price_id: z.string().optional(),
|
||||
@@ -43,7 +42,6 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
max_purchase_per_user: 0,
|
||||
max_agents: 0,
|
||||
total_amount: 0,
|
||||
upgrade_group: '',
|
||||
stripe_price_id: '',
|
||||
@@ -63,7 +61,6 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
|
||||
enabled: plan.enabled !== false,
|
||||
sort_order: Number(plan.sort_order || 0),
|
||||
max_purchase_per_user: Number(plan.max_purchase_per_user || 0),
|
||||
max_agents: Number(plan.max_agents || 0),
|
||||
total_amount: Number(plan.total_amount || 0),
|
||||
upgrade_group: plan.upgrade_group || '',
|
||||
stripe_price_id: plan.stripe_price_id || '',
|
||||
@@ -86,7 +83,6 @@ export function formValuesToPlanPayload(values: PlanFormValues): PlanPayload {
|
||||
: 0,
|
||||
sort_order: Number(values.sort_order || 0),
|
||||
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
|
||||
max_agents: Number(values.max_agents || 0),
|
||||
total_amount: Number(values.total_amount || 0),
|
||||
upgrade_group: values.upgrade_group || '',
|
||||
},
|
||||
|
||||
@@ -18,7 +18,6 @@ export const subscriptionPlanSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
sort_order: z.number(),
|
||||
max_purchase_per_user: z.number(),
|
||||
max_agents: z.number(),
|
||||
total_amount: z.number(),
|
||||
upgrade_group: z.string().optional(),
|
||||
stripe_price_id: z.string().optional(),
|
||||
|
||||
+22
-6
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getSelf } from '@/lib/api'
|
||||
import { getHeicodeBalance } from '@/lib/heicode-mcp'
|
||||
import { useStatus } from '@/hooks/use-status'
|
||||
import { useSystemConfig } from '@/hooks/use-system-config'
|
||||
import { SectionPageLayout } from '@/components/layout'
|
||||
@@ -86,15 +87,30 @@ export function Wallet(props: WalletProps) {
|
||||
useWaffoPancakePayment()
|
||||
|
||||
// Fetch and refresh user data.
|
||||
// HM IS new-api: the caller's quota lives in HM's own user row, so read it
|
||||
// directly via GET /api/user/self (GetSelf — the user's own data, no admin
|
||||
// lookup, no permission gate, freshest figures). The old path detoured the
|
||||
// balance through the /api/heicode-auth proxy to mcp-server (the AM-side
|
||||
// login service — a different project that does not own balance), which
|
||||
// 502'd on NewAPI's same-level admin check (controller/user.go GetUser).
|
||||
// Per docs/product-package/13 §6 "模型与余额" + Heicode-接口契约文档 §4,
|
||||
// the canonical balance source is mcp-server /api/user/heicode/balance
|
||||
// (server-side wraps NewAPI admin token, returns the freshest figures).
|
||||
// Fall back to NewAPI /api/user/self when the user hasn't yet been
|
||||
// mirrored into NewAPI via from-agent (HEICODE_USER_NOT_FOUND) so a
|
||||
// brand-new account still sees something instead of empty stats.
|
||||
const fetchUser = useCallback(async () => {
|
||||
try {
|
||||
setUserLoading(true)
|
||||
const heicodeBalance = await getHeicodeBalance().catch(() => null)
|
||||
if (heicodeBalance) {
|
||||
setUser({
|
||||
id: heicodeBalance.heicodeUserId,
|
||||
username: heicodeBalance.username,
|
||||
group: heicodeBalance.group,
|
||||
quota: heicodeBalance.quota,
|
||||
used_quota: heicodeBalance.usedQuota,
|
||||
request_count: heicodeBalance.requestCount,
|
||||
aff_quota: 0,
|
||||
aff_history_quota: 0,
|
||||
aff_count: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
const response = await getSelf()
|
||||
if (response.success && response.data) {
|
||||
setUser(response.data as UserWalletData)
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
"/status/": "/status/",
|
||||
"/your/endpoint": "/your/endpoint",
|
||||
"0 means unlimited": "0 表示不限",
|
||||
"0 uses the global default cap": "0 表示使用全局默认上限",
|
||||
"1 Day": "1 天",
|
||||
"Agent Deploy Limit": "Agent 部署上限",
|
||||
"1 day ago": "1 天前",
|
||||
"1 Hour": "1 小时",
|
||||
"1 hour ago": "1 小时前",
|
||||
|
||||
Reference in New Issue
Block a user