feat: add agnet artifact content proxy

This commit is contained in:
gongzhiyong
2026-05-30 12:55:04 +08:00
parent 70663f47ee
commit edf7aeaf8a
13 changed files with 877 additions and 13 deletions
@@ -1,6 +1,6 @@
# Heicode Manager 可独立执行任务计划 # Heicode Manager 可独立执行任务计划
更新时间:2026-05-27 更新时间:2026-05-30
负责人范围:Heicode Manager 端 负责人范围:Heicode Manager 端
用途:后续开发按本文逐项执行、验收和更新状态。 用途:后续开发按本文逐项执行、验收和更新状态。
@@ -47,6 +47,8 @@
| 12 | 文档口径清理 | P2 | 是 | 已完成:当前口径以 Azure Key Vault / secret_ref 为准 | | 12 | 文档口径清理 | P2 | 是 | 已完成:当前口径以 Azure Key Vault / secret_ref 为准 |
| 13 | AWS/GCP 占位提示 | P2 | 是 | 已完成:避免用户误以为 AWS/GCP 已可用 | | 13 | AWS/GCP 占位提示 | P2 | 是 | 已完成:避免用户误以为 AWS/GCP 已可用 |
| 14 | 请求 body 加密策略确认 | P2 | 是 | 已完成:桌面端 sub POST 复用模型调用 V2 body 加密 | | 14 | 请求 body 加密策略确认 | P2 | 是 | 已完成:桌面端 sub POST 复用模型调用 V2 body 加密 |
| 15 | Runtime 状态诊断与兜底产物识别 | P1 | 是 | 已完成:Manager 可独立查询 Runtime 状态,页面明确区分普通 sub / 蜂群,并提示兜底摘要不是最终交付物 |
| 16 | Artifact 完整内容代理下载 | P1 | 是 | 已完成:Manager 校验用户和 artifact 后,通过 Runtime content 接口代理下载完整产物 |
## 四、任务明细 ## 四、任务明细
@@ -273,6 +275,43 @@
- 服务端日志不打印请求 body 中的敏感字段。 - 服务端日志不打印请求 body 中的敏感字段。
- V2 加密失败时返回 `X-Heicode-Auth-Error` 和 `X-Heicode-Server-Time`,便于客户端排障。 - V2 加密失败时返回 `X-Heicode-Auth-Error` 和 `X-Heicode-Server-Time`,便于客户端排障。
### 任务 15:Runtime 状态诊断与兜底产物识别
| 项 | 内容 |
|---|---|
| 目标 | Manager 不依赖客户端或 Runtime 改接口,也能把“callback 已到”和“Runtime/Agent 是否真的产出交付物”分开展示 |
| 修改文件 | `heicode/controller/agnet_runtime_client.go`、`heicode/router/api-router.go`、`heicode/web/default/src/features/agnet-console/api.ts`、`heicode/web/default/src/features/agnet-console/pages.tsx` |
| 新增 API | `GET /api/agnet/user/deployments/:deployment_id/runtime-diagnostics` |
| 诊断来源 | 用 deployment 记录里的 `runtime_swarm_id` / `runtime_deployment_id` 查询 Runtime status;默认路径 `/api/swarms/{swarm_id}/status`,可用 `AGNET_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 TestAgnetRuntimeDiagnosticsWarnsOnCompletedRuntimeWithFailedAgents -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/agnet_artifact.go`、`heicode/controller/agnet_callback.go`、`heicode/controller/agnet_runtime_client.go`、`heicode/router/api-router.go`、`heicode/web/default/src/features/agnet-console/pages.tsx` |
| 新增 API | `GET /api/agnet/user/deployments/:deployment_id/artifacts/:artifact_id/content` |
| Runtime 目标路径 | 默认 `GET /api/swarms/{swarm_id}/artifacts/{artifact_id}/content`,可用 `AGNET_RUNTIME_ARTIFACT_CONTENT_PATH` / `SWARM_RUNTIME_ARTIFACT_CONTENT_PATH` 配置 |
| 安全边界 | Manager 先校验当前用户拥有 deployment,再校验 artifact 属于该 deployment;不暴露 Azure Blob 凭据、SAS URL 或 Runtime 内网信息 |
| 测试 | `go test ./controller -run TestAgnetArtifactContentProxiesRuntimeContent -count=1` |
验收标准:
- 不能直接用用户传入的 URI 下载,必须以 Manager 已落库 artifact 为准。
- 响应透传 Runtime 的文件正文、`Content-Type` 和 `Content-Disposition`。
- Runtime 未配置或 content 拉取失败时返回明确业务错误。
- 页面 artifact 卡片提供“下载产物”,但兜底摘要仍需标记为非最终业务交付物。
## 五、推荐执行批次 ## 五、推荐执行批次
### 批次 A:最小 Manager 闭环 ### 批次 A:最小 Manager 闭环
@@ -307,6 +346,8 @@
| 4 | 文档口径清理 | | 4 | 文档口径清理 |
| 5 | AWS/GCP 占位提示 | | 5 | AWS/GCP 占位提示 |
| 6 | 请求 body 加密策略确认 | | 6 | 请求 body 加密策略确认 |
| 7 | Runtime 状态诊断与兜底产物识别 |
| 8 | Artifact 完整内容代理下载 |
完成批次 C 后,Manager 端应具备清晰的任务视角、持久化上下文、准确页面口径和更少误导。 完成批次 C 后,Manager 端应具备清晰的任务视角、持久化上下文、准确页面口径和更少误导。
@@ -356,6 +397,8 @@ Manager 独立任务完成,不等于蜂群生产闭环完成。本文完成的
| 审计 | task/deployment/correlation_id 下能聚合审计、审批、artifact、callback | | 审计 | task/deployment/correlation_id 下能聚合审计、审批、artifact、callback |
| 安全 | API、日志、页面不出现明文长期密钥 | | 安全 | API、日志、页面不出现明文长期密钥 |
| 请求加密 | 桌面端 sub 请求支持与模型调用一致的 V2 body 加密;浏览器后台普通 JSON 兼容路径不受影响 | | 请求加密 | 桌面端 sub 请求支持与模型调用一致的 V2 body 加密;浏览器后台普通 JSON 兼容路径不受影响 |
| Runtime 诊断 | Manager 页面能显示 Runtime 真实状态来源、失败 Agent、兜底产物和普通 sub / 蜂群模式归属 |
| Artifact 内容 | Manager 用户态接口能代理下载 Runtime 保存的完整 artifact 内容,且不暴露存储凭据 |
| 口径 | 页面和文档不把本地占位/模拟事件说成真实 Runtime | | 口径 | 页面和文档不把本地占位/模拟事件说成真实 Runtime |
## 八、执行时不能突破的边界 ## 八、执行时不能突破的边界
@@ -1,8 +1,8 @@
# Heicode Manager 蜂群模式进度清单 # Heicode Manager 普通 sub 与蜂群模式进度清单
更新时间:2026-05-28 更新时间:2026-05-30
负责人范围:Heicode Manager 端 负责人范围:Heicode Manager 端
用途:给负责人、上级和联调同学快速确认 Manager 端在蜂群模式下已经具备什么、还要做什么、哪些需要客户端或蜂群项目配合。 用途:给负责人、上级和联调同学快速确认 Manager 端在普通 sub 与蜂群模式下已经具备什么、还要做什么、哪些需要客户端或 Agent Manager / 蜂群项目配合。
## 资料来源 ## 资料来源
@@ -19,6 +19,11 @@
蜂群模式不是普通 sub 敏捷/瀑布本身。普通 sub 是 Heicode 的任务组织方式;蜂群是 Agnet/Swarm Runtime 的执行方式。 蜂群模式不是普通 sub 敏捷/瀑布本身。普通 sub 是 Heicode 的任务组织方式;蜂群是 Agnet/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 桌面客户端 | 用户主体验 | 输入目标、持续补充需求、查看反馈、审批高危操作、接收交付结果 | 直接配置 AKS、模型供应商、完整蜂群 payload |
@@ -55,10 +60,12 @@ Heicode 桌面客户端
| `/api/swarms` 兼容入口 | 已有用户态 `POST /api/swarms`,内部走 Manager deployment 创建,并作为 adapter source 记录 | `heicode/router/api-router.go`、`AgnetCreateUserSwarm` | | `/api/swarms` 兼容入口 | 已有用户态 `POST /api/swarms`,内部走 Manager deployment 创建,并作为 adapter source 记录 | `heicode/router/api-router.go`、`AgnetCreateUserSwarm` |
| Runtime 创建桥接 | 已能按配置调用 Runtime 创建接口,默认路径 `/api/agnet/deployments`,可用环境变量改为蜂群创建路径 | `heicode/controller/agnet_runtime_client.go` | | Runtime 创建桥接 | 已能按配置调用 Runtime 创建接口,默认路径 `/api/agnet/deployments`,可用环境变量改为蜂群创建路径 | `heicode/controller/agnet_runtime_client.go` |
| Runtime stop 桥接 | 已能在停止 Manager deployment 时调用 Runtime stop | `heicode/controller/agnet_runtime_client.go` | | Runtime stop 桥接 | 已能在停止 Manager deployment 时调用 Runtime stop | `heicode/controller/agnet_runtime_client.go` |
| Runtime 状态诊断 | 已新增用户态只读诊断接口,按 `runtime_mode` 区分普通 sub / 蜂群,查询 Runtime status 并识别 failed agent、兜底摘要 artifact、零 token 用量等异常 | `heicode/controller/agnet_runtime_client.go`、`AgnetGetUserDeploymentRuntimeDiagnostics` |
| callback 接收 | 已有 `POST /api/agnet/callbacks/swarm-events` | `heicode/controller/agnet_callback.go` | | callback 接收 | 已有 `POST /api/agnet/callbacks/swarm-events` | `heicode/controller/agnet_callback.go` |
| callback 鉴权 | 支持 `X-Agnet-Service-Token` 和 HMAC 签名校验,并可从 Key Vault ref 读取签名密钥 | `heicode/controller/agnet_callback.go` | | callback 鉴权 | 支持 `X-Agnet-Service-Token` 和 HMAC 签名校验,并可从 Key Vault ref 读取签名密钥 | `heicode/controller/agnet_callback.go` |
| callback 幂等 | `event_id` / `idempotency_key` 去重,重复回调返回成功但不重复写 | `heicode/model/agnet_callback.go` | | callback 幂等 | `event_id` / `idempotency_key` 去重,重复回调返回成功但不重复写 | `heicode/model/agnet_callback.go` |
| artifact 落库 | `artifact.created` 可生成 artifact 记录,支持用户态列表查询 | `heicode/model/agnet_artifact.go`、`AgnetListUserDeploymentArtifacts` | | artifact 落库 | `artifact.created` 可生成 artifact 记录,支持用户态列表查询 | `heicode/model/agnet_artifact.go`、`AgnetListUserDeploymentArtifacts` |
| artifact 完整内容代理 | 已新增用户态 content 下载接口,Manager 校验 deployment/artifact 权限后代理 Runtime content 接口读取完整产物 | `AgnetGetUserDeploymentArtifactContent`、`callAgnetRuntimeArtifactContent` |
| approval 回调 | `approval.requested` 可转成 Manager 审批记录 | `heicode/controller/agnet_callback.go` | | approval 回调 | `approval.requested` 可转成 Manager 审批记录 | `heicode/controller/agnet_callback.go` |
| 审批结果回传 Runtime | 用户 approve/reject 后,Manager 可按配置 POST 回 Runtime approval decision,且不发送 `secret_ref` | `heicode/controller/agnet_approval.go`、`heicode/controller/agnet_runtime_client.go` | | 审批结果回传 Runtime | 用户 approve/reject 后,Manager 可按配置 POST 回 Runtime approval decision,且不发送 `secret_ref` | `heicode/controller/agnet_approval.go`、`heicode/controller/agnet_runtime_client.go` |
| timeline 聚合 | 用户态 timeline 聚合 audit、callbacks、artifacts、sk_snapshots | `AgnetGetUserDeploymentTimeline` | | timeline 聚合 | 用户态 timeline 聚合 audit、callbacks、artifacts、sk_snapshots | `AgnetGetUserDeploymentTimeline` |
@@ -78,7 +85,9 @@ Heicode 桌面客户端
| P0 | Runtime 联调配置模板 | 已完成:两份 Agent Manager 对接任务清单已写清 `AGNET_RUNTIME_*`、callback URL、service token/HMAC 方式和验收步骤 | 是 | 蜂群项目按模板能调用 Manager callback | | P0 | Runtime 联调配置模板 | 已完成:两份 Agent Manager 对接任务清单已写清 `AGNET_RUNTIME_*`、callback URL、service token/HMAC 方式和验收步骤 | 是 | 蜂群项目按模板能调用 Manager callback |
| P1 | 审批结果回传 Runtime 联调 | Manager adapter 已有;仍需要 Runtime 提供接收接口并验证状态继续/停止 | 需要 Runtime 接口 | 审批通过/拒绝后 Runtime 状态能继续或停止 | | P1 | 审批结果回传 Runtime 联调 | Manager adapter 已有;仍需要 Runtime 提供接收接口并验证状态继续/停止 | 需要 Runtime 接口 | 审批通过/拒绝后 Runtime 状态能继续或停止 |
| P1 | Artifact 展示优化 | Manager 端已完成:页面展示 artifact 类型、摘要和 URI;真实 `code_patch/document/test_report/deployment_manifest` 仍需 Runtime 输出 | 需要 Runtime 数据 | artifact 页面/详情能按类型展示摘要和链接 | | 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 | 任务图/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 | 日志/指标真实来源标识 | 已完成: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 | 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 | | P1 | 高危审批客户端联动文档 | 已完成:`docs/integration/heicode-desktop-sub-agile-api.md` 已包含 approval 查询、approve/reject、awaiting_approval 流程 | 是 | 客户端文档补齐 approval flow |
@@ -116,6 +125,7 @@ Heicode 桌面客户端
| HeiCode-Swarm 项目等于正式 Heicode 桌面客户端 | 不是。它有自己的 `desktop-client` 演示端,正式链路应走 Heicode 桌面客户端 -> Manager -> Runtime | | HeiCode-Swarm 项目等于正式 Heicode 桌面客户端 | 不是。它有自己的 `desktop-client` 演示端,正式链路应走 Heicode 桌面客户端 -> Manager -> Runtime |
| `/api/swarms` 已等于真实 Runtime Swarm Run | 不是。Manager 侧已有 adapter 入口,但是否真实创建 Swarm Run 取决于 Runtime 配置和蜂群接口 | | `/api/swarms` 已等于真实 Runtime Swarm Run | 不是。Manager 侧已有 adapter 入口,但是否真实创建 Swarm Run 取决于 Runtime 配置和蜂群接口 |
| artifact/timeline 有接口就等于有真实产物 | 不是。Manager 能接和展示,真实产物必须由 Runtime 回调 | | artifact/timeline 有接口就等于有真实产物 | 不是。Manager 能接和展示,真实产物必须由 Runtime 回调 |
| `Runtime execution summary` 就等于最终交付物 | 不是。Manager 页面会标记这是兜底摘要;真实最终交付物必须是 Runtime/Agent 返回的 `code_patch`、`document`、`test_report`、Git branch/commit、部署地址等可核对 artifact |
| 高危审批在 Manager 里点完就闭环 | 不是。产品要求桌面客户端主审批,并且 Runtime 要收到 decision | | 高危审批在 Manager 里点完就闭环 | 不是。产品要求桌面客户端主审批,并且 Runtime 要收到 decision |
| 本地测试通过就等于生产接口全通 | 不是。本地 router/controller/middleware 测试能证明代码能力;生产仍必须确认对应镜像、路由和认证配置已生效 | | 本地测试通过就等于生产接口全通 | 不是。本地 router/controller/middleware 测试能证明代码能力;生产仍必须确认对应镜像、路由和认证配置已生效 |
| Agent budget 就等于真实收费 | 不是。当前 `budget` 是执行上限和审计字段;真实收费需要 Runtime/Agent Manager 回传可核对 usage | | Agent budget 就等于真实收费 | 不是。当前 `budget` 是执行上限和审计字段;真实收费需要 Runtime/Agent Manager 回传可核对 usage |
@@ -1,6 +1,6 @@
# Heicode 桌面客户端 sub 敏捷流程 API 对接文档 # Heicode 桌面客户端 sub 敏捷流程 API 对接文档
更新时间:2026-05-28 更新时间:2026-05-30
适用范围:Heicode Desktop / 本地服务对接 Heicode Manager,跑通普通 sub 模式敏捷开发流程。 适用范围:Heicode Desktop / 本地服务对接 Heicode Manager,跑通普通 sub 模式敏捷开发流程。
Manager 生产地址:`https://code.xinghanlab.com` Manager 生产地址:`https://code.xinghanlab.com`
@@ -145,12 +145,82 @@ Accept: application/json
4. 当任务卡生成后,创建 deployment draft:POST /api/agnet/user/tasks/{task_id}/deployment-draft 4. 当任务卡生成后,创建 deployment draft:POST /api/agnet/user/tasks/{task_id}/deployment-draft
5. 创建 Manager deployment:POST /api/agnet/user/deployments 5. 创建 Manager deployment:POST /api/agnet/user/deployments
6. 轮询 deployment detail / events / timeline 6. 轮询 deployment detail / events / timeline
7. 展示 artifacts / sk-snapshots / logs / metrics 7. 查询 runtime-diagnostics,区分 callback 已到、Runtime 状态、失败 Agent 和兜底摘要 artifact
8. 如出现 approval,桌面端展示审批并调用 approve/reject 8. 查询 artifacts 列表;如果需要完整文件,调用 artifact content 代理接口下载
9. 完成后继续迭代或停止 deployment 9. 展示 sk-snapshots / logs / metrics
10. 如出现 approval,桌面端展示审批并调用 approve/reject
11. 完成后继续迭代或停止 deployment
``` ```
### 3.1 2026-05-28 生产验证结果 ### 3.1 Runtime 诊断接口
客户端展示普通 sub 结果时,不能只看 deployment `status=completed`。Manager 已提供只读诊断接口,用来识别 Runtime 是否真的完成、是否有 failed agent、是否只返回兜底摘要 artifact。
```http
GET /api/agnet/user/deployments/{deployment_id}/runtime-diagnostics
```
返回核心字段:
| 字段 | 说明 |
|---|---|
| `runtime_mode` | `agnet` 表示普通 sub Runtime;`swarm` 表示蜂群 Runtime |
| `runtime_swarm_id` / `runtime_deployment_id` | Manager 保存的 Runtime 映射 |
| `data_source` | 当前诊断来源,正常为 `runtime_status` |
| `status` / `phase` | Runtime 直接返回的状态和阶段 |
| `agents` | Runtime 返回的 Agent 状态列表 |
| `artifacts` | Runtime status 里的产物摘要 |
| `metrics` | Runtime status 里的 usage/耗时等指标 |
| `warnings` | Manager 根据 Runtime status 识别出的异常 |
常见 `warnings`:
| warning | 客户端展示含义 |
|---|---|
| `runtime_agent_failed` | 存在失败 Agent,不能把任务说成完整交付 |
| `runtime_completed_with_failed_agents` | Runtime 总状态 completed,但内部 Agent 有失败,需要提示“执行异常完成” |
| `runtime_summary_artifact_only` | 只有 `Runtime execution summary` 兜底摘要,不是最终业务交付物 |
| `runtime_zero_model_usage` | Runtime 回传模型 token 为 0,说明 Agent 可能没有真实调用模型 |
| `runtime_status_query_failed` | Manager 无法查询 Runtime status,只能展示已落库 callback |
客户端建议:
1. deployment detail / timeline / artifacts 仍按原接口展示。
2. 若 `warnings` 包含 `runtime_summary_artifact_only`,需要提示“当前没有最终交付产物,请查看运行日志/等待 Runtime 修复”。
3. 若 `runtime_mode=agnet`,按普通 sub 敏捷展示;若 `runtime_mode=swarm`,按蜂群模式展示任务图/Agent 编队,不能混用两套文案。
4. 该接口只读,失败时不应中断已有 timeline/artifact 展示。
### 3.2 Artifact 完整内容下载
普通 sub 的 `artifact.created` callback 只保存摘要、URI、大小、hash 和 metadata。客户端或 Manager 页面需要完整产物正文时,先查列表,再走 Manager 用户态 content 代理接口,不要直接暴露 Blob 凭据、SAS URL 或 Runtime 内网地址。
列表:
```http
GET /api/agnet/user/deployments/{deployment_id}/artifacts
```
下载:
```http
GET /api/agnet/user/deployments/{deployment_id}/artifacts/{artifact_id}/content
```
Manager 行为:
1. 校验当前用户拥有该 `deployment_id`。
2. 校验 `artifact_id` 属于该 deployment。
3. 使用 deployment 里保存的 `runtime_swarm_id` / `runtime_deployment_id` 请求 Runtime:
`GET /api/swarms/{swarm_id}/artifacts/{artifact_id}/content`。
4. 透传 Runtime 返回的文件内容、`Content-Type`、`Content-Disposition` 等安全响应头。
5. 如果 Runtime 未配置、artifact 不存在或 Runtime 返回错误,返回业务错误,不伪造内容。
页面说明:
- Manager Web 任务总览中的 artifact 卡片会显示“下载产物”。
- 如果 artifact 是 `Runtime execution summary` 兜底摘要,页面会明确提示它不是最终业务交付物。
### 3.3 2026-05-28 生产验证结果
本节记录已经按“桌面客户端应调用的顺序”在生产环境跑过的结果,客户端可按同一顺序和参数形状对接。 本节记录已经按“桌面客户端应调用的顺序”在生产环境跑过的结果,客户端可按同一顺序和参数形状对接。
@@ -215,7 +285,7 @@ Manager 登录
- 如果客户端需要从自然语言创建任务,必须先完成 Heicode 登录并拿到 `heicode_access_token`。 - 如果客户端需要从自然语言创建任务,必须先完成 Heicode 登录并拿到 `heicode_access_token`。
- 当前 create / callback / detail / metrics / events / timeline / stop 已真实有效;artifacts / sk-snapshots 需要 Runtime 在真实任务中回写 `artifact.created` / `sk_tool.*` 后才会有数据。 - 当前 create / callback / detail / metrics / events / timeline / stop 已真实有效;artifacts / sk-snapshots 需要 Runtime 在真实任务中回写 `artifact.created` / `sk_tool.*` 后才会有数据。
### 3.2 桌面客户端联调结论 ### 3.4 桌面客户端联调结论
普通 sub 模式可以开始桌面客户端联调。建议先按 Windows 最新客户端跑通,因为生产已有 Windows 设备绑定记录;macOS 端必须先确认客户端版本和 Keychain 凭据。 普通 sub 模式可以开始桌面客户端联调。建议先按 Windows 最新客户端跑通,因为生产已有 Windows 设备绑定记录;macOS 端必须先确认客户端版本和 Keychain 凭据。
+50
View File
@@ -1,11 +1,13 @@
package controller package controller
import ( import (
"context"
"crypto/hmac" "crypto/hmac"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io" "io"
"net/http"
"os" "os"
"sort" "sort"
"strconv" "strconv"
@@ -768,6 +770,54 @@ func AgnetListUserDeploymentArtifacts(c *gin.Context) {
common.ApiSuccess(c, gin.H{"deployment_id": record.DeploymentID, "artifacts": items, "items": items, "total": len(items)}) common.ApiSuccess(c, gin.H{"deployment_id": record.DeploymentID, "artifacts": items, "items": items, "total": len(items)})
} }
func AgnetGetUserDeploymentArtifactContent(c *gin.Context) {
record, ok := requireAuthenticatedUserAgnetDeployment(c)
if !ok {
return
}
artifactID := strings.TrimSpace(c.Param("artifact_id"))
if artifactID == "" {
agnetError(c, "ARTIFACT_ID_REQUIRED", "artifact_id is required")
return
}
artifact, found, err := model.GetAgnetArtifactByDeployment(record.DeploymentID, artifactID)
if err != nil {
common.SysLog("AgnetGetUserDeploymentArtifactContent: " + err.Error())
agnetError(c, "ARTIFACT_QUERY_FAILED", "failed to query artifact")
return
}
if !found {
agnetError(c, "ARTIFACT_NOT_FOUND", "artifact not found")
return
}
cfg := agnetRuntimeClientConfigForMode(agnetRuntimeModeForRecord(record))
if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" {
agnetError(c, "RUNTIME_NOT_CONFIGURED", "runtime is not configured")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), cfg.Timeout)
defer cancel()
resp, err := callAgnetRuntimeArtifactContent(ctx, cfg, record, artifact.ArtifactID)
if err != nil {
common.SysLog("AgnetGetUserDeploymentArtifactContent: " + err.Error())
agnetError(c, "ARTIFACT_CONTENT_FETCH_FAILED", "failed to fetch artifact content")
return
}
defer resp.Body.Close()
headers := map[string]string{}
for _, key := range []string{"Content-Disposition", "ETag", "Last-Modified", "Cache-Control"} {
if value := strings.TrimSpace(resp.Header.Get(key)); value != "" {
headers[key] = value
}
}
contentType := strings.TrimSpace(resp.Header.Get("Content-Type"))
if contentType == "" {
contentType = "application/octet-stream"
}
c.DataFromReader(http.StatusOK, resp.ContentLength, contentType, resp.Body, headers)
}
func callbackPayloadMap(row model.AgnetCallbackEvent) map[string]any { func callbackPayloadMap(row model.AgnetCallbackEvent) map[string]any {
var payload agnetCallbackEnvelope var payload agnetCallbackEnvelope
if err := common.UnmarshalJsonStr(row.PayloadJSON, &payload); err != nil { if err := common.UnmarshalJsonStr(row.PayloadJSON, &payload); err != nil {
@@ -397,6 +397,135 @@ func TestAgnetRuntimeShadowCreateStoresRuntimeMapping(t *testing.T) {
require.Equal(t, "accepted", stored.RuntimeState) require.Equal(t, "accepted", stored.RuntimeState)
} }
func TestAgnetRuntimeDiagnosticsWarnsOnCompletedRuntimeWithFailedAgents(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "Bearer service-token", r.Header.Get("Authorization"))
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/agnet/deployments":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"deployment_id":"runtime-dep-1","swarm_id":"swarm-1","status":"running"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/api/swarms/swarm-1/status":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"deployment_id":"swarm-1",
"swarm_id":"swarm-1",
"status":"completed",
"phase":"development",
"agents":[{"agent_id":"agi_backend_1","role":"backend","status":"failed","output":"Cannot connect to host agent svc"}],
"artifacts":[{"artifact_id":"art_summary","artifact_type":"document","title":"Runtime execution summary","summary":"Runtime completed without per-agent artifacts; review swarm logs for details.","uri":"runtime://swarm-1/artifacts/summary"}],
"metrics":{"tokens_used":0}
}`))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
t.Setenv("AGNET_RUNTIME_ENABLED", "true")
t.Setenv("AGNET_RUNTIME_ASYNC", "false")
t.Setenv("AGNET_RUNTIME_BASE_URL", server.URL)
t.Setenv("AGNET_RUNTIME_SERVICE_TOKEN", "service-token")
plan := baseAgnetResourceGrantPlan()
plan.UserContext.UserID = "7"
for idx := range plan.ResourceGrants {
plan.ResourceGrants[idx].UserID = "7"
}
for agentIdx := range plan.Agents {
for grantIdx := range plan.Agents[agentIdx].ResourceGrants {
plan.Agents[agentIdx].ResourceGrants[grantIdx].UserID = "7"
}
}
createRecorder, envelope := postAgnetCreateUserDeployment(t, 7, plan)
require.True(t, envelope.Success, createRecorder.Body.String())
var createBody map[string]any
require.NoError(t, common.Unmarshal(createRecorder.Body.Bytes(), &createBody))
deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set("id", 7)
ctx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/user/deployments/"+deploymentID+"/runtime-diagnostics", nil)
AgnetGetUserDeploymentRuntimeDiagnostics(ctx)
require.Equal(t, http.StatusOK, recorder.Code)
require.Contains(t, recorder.Body.String(), `"data_source":"runtime_status"`)
require.Contains(t, recorder.Body.String(), `"runtime_agent_failed"`)
require.Contains(t, recorder.Body.String(), `"runtime_completed_with_failed_agents"`)
require.Contains(t, recorder.Body.String(), `"runtime_summary_artifact_only"`)
require.Contains(t, recorder.Body.String(), `"runtime_zero_model_usage"`)
}
func TestAgnetArtifactContentProxiesRuntimeContent(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "Bearer service-token", r.Header.Get("Authorization"))
switch {
case r.Method == http.MethodPost && r.URL.Path == "/api/agnet/deployments":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"deployment_id":"runtime-dep-1","swarm_id":"swarm-1","status":"running"}}`))
case r.Method == http.MethodGet && r.URL.Path == "/api/swarms/swarm-1/artifacts/art-1/content":
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="art-1.txt"`)
_, _ = w.Write([]byte("artifact body"))
default:
http.NotFound(w, r)
}
}))
defer server.Close()
t.Setenv("AGNET_RUNTIME_ENABLED", "true")
t.Setenv("AGNET_RUNTIME_ASYNC", "false")
t.Setenv("AGNET_RUNTIME_BASE_URL", server.URL)
t.Setenv("AGNET_RUNTIME_SERVICE_TOKEN", "service-token")
plan := baseAgnetResourceGrantPlan()
plan.UserContext.UserID = "7"
for idx := range plan.ResourceGrants {
plan.ResourceGrants[idx].UserID = "7"
}
for agentIdx := range plan.Agents {
for grantIdx := range plan.Agents[agentIdx].ResourceGrants {
plan.Agents[agentIdx].ResourceGrants[grantIdx].UserID = "7"
}
}
createRecorder, envelope := postAgnetCreateUserDeployment(t, 7, plan)
require.True(t, envelope.Success, createRecorder.Body.String())
var createBody map[string]any
require.NoError(t, common.Unmarshal(createRecorder.Body.Bytes(), &createBody))
deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string)
require.NoError(t, model.UpsertAgnetArtifact(&model.AgnetArtifact{
ArtifactID: "art-1",
DeploymentID: deploymentID,
UserID: "7",
URI: "runtime://swarm-1/artifacts/art-1",
CreatedAtMs: time.Now().UnixMilli(),
}))
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set("id", 7)
ctx.Params = gin.Params{
{Key: "deployment_id", Value: deploymentID},
{Key: "artifact_id", Value: "art-1"},
}
ctx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/user/deployments/"+deploymentID+"/artifacts/art-1/content", nil)
AgnetGetUserDeploymentArtifactContent(ctx)
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, "artifact body", recorder.Body.String())
require.Equal(t, "text/plain; charset=utf-8", recorder.Header().Get("Content-Type"))
require.Equal(t, `attachment; filename="art-1.txt"`, recorder.Header().Get("Content-Disposition"))
}
func TestAgnetRuntimeShadowCreateFailureDoesNotFailLocalDeployment(t *testing.T) { func TestAgnetRuntimeShadowCreateFailureDoesNotFailLocalDeployment(t *testing.T) {
db := setupAgnetControlPlaneTestDB(t) db := setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t) resetAgnetControlPlaneState(t)
+266
View File
@@ -32,6 +32,8 @@ type agnetRuntimeConfig struct {
Token string Token string
CreatePath string CreatePath string
HealthPath string HealthPath string
StatusPath string
ArtifactContentPath string
StopPath string StopPath string
ApprovalDecisionPath string ApprovalDecisionPath string
Timeout time.Duration Timeout time.Duration
@@ -44,6 +46,25 @@ type agnetRuntimeSyncResult struct {
RawStatusCode int RawStatusCode int
} }
type agnetRuntimeDiagnostics struct {
DeploymentID string `json:"deployment_id"`
RuntimeMode string `json:"runtime_mode"`
SubMode string `json:"sub_mode"`
RuntimeDeploymentID string `json:"runtime_deployment_id,omitempty"`
RuntimeSwarmID string `json:"runtime_swarm_id,omitempty"`
DataSource string `json:"data_source"`
HTTPStatus int `json:"http_status,omitempty"`
Status string `json:"status,omitempty"`
Phase string `json:"phase,omitempty"`
Progress any `json:"progress,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
Agents []gin.H `json:"agents"`
Artifacts []gin.H `json:"artifacts"`
Metrics map[string]any `json:"metrics,omitempty"`
Warnings []string `json:"warnings"`
CheckedAt string `json:"checked_at"`
}
func normalizeAgnetRuntimeMode(value string) string { func normalizeAgnetRuntimeMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) { switch strings.ToLower(strings.TrimSpace(value)) {
case agnetRuntimeModeSwarm: case agnetRuntimeModeSwarm:
@@ -101,6 +122,8 @@ func agnetRuntimeClientConfigForMode(mode string) agnetRuntimeConfig {
Token: strings.TrimSpace(common.GetEnvOrDefaultString(prefix+"SERVICE_TOKEN", "")), Token: strings.TrimSpace(common.GetEnvOrDefaultString(prefix+"SERVICE_TOKEN", "")),
CreatePath: common.GetEnvOrDefaultString(prefix+"CREATE_PATH", defaultCreatePath), CreatePath: common.GetEnvOrDefaultString(prefix+"CREATE_PATH", defaultCreatePath),
HealthPath: common.GetEnvOrDefaultString(prefix+"HEALTH_PATH", "/api/agnet/health"), HealthPath: common.GetEnvOrDefaultString(prefix+"HEALTH_PATH", "/api/agnet/health"),
StatusPath: common.GetEnvOrDefaultString(prefix+"STATUS_PATH", "/api/swarms/{swarm_id}/status"),
ArtifactContentPath: common.GetEnvOrDefaultString(prefix+"ARTIFACT_CONTENT_PATH", "/api/swarms/{swarm_id}/artifacts/{artifact_id}/content"),
StopPath: common.GetEnvOrDefaultString(prefix+"STOP_PATH", defaultStopPath), StopPath: common.GetEnvOrDefaultString(prefix+"STOP_PATH", defaultStopPath),
ApprovalDecisionPath: common.GetEnvOrDefaultString(prefix+"APPROVAL_DECISION_PATH", defaultApprovalPath), ApprovalDecisionPath: common.GetEnvOrDefaultString(prefix+"APPROVAL_DECISION_PATH", defaultApprovalPath),
Timeout: time.Duration(timeoutSec) * time.Second, Timeout: time.Duration(timeoutSec) * time.Second,
@@ -505,6 +528,249 @@ func agnetRuntimeStopPathForRecord(cfg agnetRuntimeConfig, record agnetDeploymen
return replacer.Replace(path) return replacer.Replace(path)
} }
func agnetRuntimeStatusPathForRecord(cfg agnetRuntimeConfig, record agnetDeploymentRecord) string {
path := strings.TrimSpace(cfg.StatusPath)
if path == "" {
path = "/api/swarms/{swarm_id}/status"
}
replacer := strings.NewReplacer(
"{swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{runtime_swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{deployment_id}", url.PathEscape(strings.TrimSpace(record.RuntimeDeploymentID)),
"{runtime_deployment_id}", url.PathEscape(strings.TrimSpace(record.RuntimeDeploymentID)),
"{manager_deployment_id}", url.PathEscape(strings.TrimSpace(record.DeploymentID)),
)
return replacer.Replace(path)
}
func agnetRuntimeArtifactContentPathForRecord(cfg agnetRuntimeConfig, record agnetDeploymentRecord, artifactID string) string {
path := strings.TrimSpace(cfg.ArtifactContentPath)
if path == "" {
path = "/api/swarms/{swarm_id}/artifacts/{artifact_id}/content"
}
replacer := strings.NewReplacer(
"{swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{runtime_swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{deployment_id}", url.PathEscape(strings.TrimSpace(record.RuntimeDeploymentID)),
"{runtime_deployment_id}", url.PathEscape(strings.TrimSpace(record.RuntimeDeploymentID)),
"{manager_deployment_id}", url.PathEscape(strings.TrimSpace(record.DeploymentID)),
"{artifact_id}", url.PathEscape(strings.TrimSpace(artifactID)),
)
return replacer.Replace(path)
}
func callAgnetRuntimeStatus(ctx context.Context, cfg agnetRuntimeConfig, record agnetDeploymentRecord) (map[string]any, int, error) {
if strings.TrimSpace(record.RuntimeSwarmID) == "" && strings.TrimSpace(record.RuntimeDeploymentID) == "" {
return nil, 0, errors.New("runtime identifiers missing")
}
endpoint, err := agnetRuntimeURL(cfg.BaseURL, agnetRuntimeStatusPathForRecord(cfg, record))
if err != nil {
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, 0, err
}
agnetRuntimeHeaders(req, cfg, record)
client := &http.Client{Timeout: cfg.Timeout}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return nil, resp.StatusCode, readErr
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, resp.StatusCode, fmt.Errorf("runtime status returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var envelope map[string]any
if len(body) > 0 {
if err := common.Unmarshal(body, &envelope); err != nil {
return nil, resp.StatusCode, err
}
}
if message := agnetRuntimeEnvelopeError(envelope); message != "" {
return nil, resp.StatusCode, errors.New(message)
}
return extractAgnetRuntimeData(envelope), resp.StatusCode, nil
}
func callAgnetRuntimeArtifactContent(ctx context.Context, cfg agnetRuntimeConfig, record agnetDeploymentRecord, artifactID string) (*http.Response, error) {
if strings.TrimSpace(artifactID) == "" {
return nil, errors.New("artifact_id is required")
}
if strings.TrimSpace(record.RuntimeSwarmID) == "" && strings.TrimSpace(record.RuntimeDeploymentID) == "" {
return nil, errors.New("runtime identifiers missing")
}
endpoint, err := agnetRuntimeURL(cfg.BaseURL, agnetRuntimeArtifactContentPathForRecord(cfg, record, artifactID))
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
agnetRuntimeHeaders(req, cfg, record)
req.Header.Set("Accept", "*/*")
client := &http.Client{Timeout: cfg.Timeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return resp, nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("runtime artifact content returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
func mapSliceFromAny(value any) []gin.H {
raw, ok := value.([]any)
if !ok {
return nil
}
items := make([]gin.H, 0, len(raw))
for _, entry := range raw {
if item, ok := entry.(map[string]any); ok {
items = append(items, gin.H(item))
}
}
return items
}
func mapFromAny(value any) map[string]any {
if item, ok := value.(map[string]any); ok {
return item
}
return nil
}
func runtimeAgentHasFailed(agents []gin.H) bool {
for _, agent := range agents {
status := strings.ToLower(strings.TrimSpace(fmt.Sprint(agent["status"])))
if strings.Contains(status, "fail") ||
strings.Contains(status, "crash") ||
strings.Contains(status, "error") {
return true
}
}
return false
}
func runtimeArtifactsAreSummaryOnly(artifacts []gin.H) bool {
if len(artifacts) == 0 {
return false
}
for _, artifact := range artifacts {
title := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["title"])))
summary := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["summary"])))
uri := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["uri"])))
if strings.Contains(summary, "without per-agent artifacts") ||
strings.Contains(title, "runtime execution summary") ||
strings.Contains(uri, "/artifacts/summary") {
continue
}
return false
}
return true
}
func buildAgnetRuntimeDiagnostics(record agnetDeploymentRecord, data map[string]any, httpStatus int, source string) agnetRuntimeDiagnostics {
agents := mapSliceFromAny(data["agents"])
artifacts := mapSliceFromAny(data["artifacts"])
status := stringFromMap(data, "runtime_status", "status")
metrics := mapFromAny(data["metrics"])
warnings := make([]string, 0, 4)
agentFailed := runtimeAgentHasFailed(agents)
if agentFailed {
warnings = append(warnings, "runtime_agent_failed")
}
if strings.Contains(strings.ToLower(status), "completed") && agentFailed {
warnings = append(warnings, "runtime_completed_with_failed_agents")
}
if runtimeArtifactsAreSummaryOnly(artifacts) {
warnings = append(warnings, "runtime_summary_artifact_only")
}
if metrics != nil {
tokens := fmt.Sprint(metrics["tokens_used"])
if tokens == "0" || tokens == "0.0" {
warnings = append(warnings, "runtime_zero_model_usage")
}
}
return agnetRuntimeDiagnostics{
DeploymentID: record.DeploymentID,
RuntimeMode: agnetRuntimeModeForRecord(record),
SubMode: record.SubMode,
RuntimeDeploymentID: record.RuntimeDeploymentID,
RuntimeSwarmID: record.RuntimeSwarmID,
DataSource: source,
HTTPStatus: httpStatus,
Status: status,
Phase: stringFromMap(data, "phase", "stage"),
Progress: data["progress"],
ErrorMessage: stringFromMap(data, "error_message", "failure_reason", "error"),
Agents: agents,
Artifacts: artifacts,
Metrics: metrics,
Warnings: warnings,
CheckedAt: agnetNow(),
}
}
func agnetRuntimeDiagnosticsForRecord(ctx context.Context, record agnetDeploymentRecord) agnetRuntimeDiagnostics {
mode := agnetRuntimeModeForRecord(record)
cfg := agnetRuntimeClientConfigForMode(mode)
if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" {
return agnetRuntimeDiagnostics{
DeploymentID: record.DeploymentID,
RuntimeMode: mode,
SubMode: record.SubMode,
DataSource: "not_configured",
Warnings: []string{"runtime_not_configured"},
CheckedAt: agnetNow(),
}
}
if strings.TrimSpace(record.RuntimeSwarmID) == "" && strings.TrimSpace(record.RuntimeDeploymentID) == "" {
return agnetRuntimeDiagnostics{
DeploymentID: record.DeploymentID,
RuntimeMode: mode,
SubMode: record.SubMode,
DataSource: "missing_runtime_id",
Warnings: []string{"runtime_identifiers_missing"},
CheckedAt: agnetNow(),
}
}
data, status, err := callAgnetRuntimeStatus(ctx, cfg, record)
if err != nil {
return agnetRuntimeDiagnostics{
DeploymentID: record.DeploymentID,
RuntimeMode: mode,
SubMode: record.SubMode,
RuntimeDeploymentID: record.RuntimeDeploymentID,
RuntimeSwarmID: record.RuntimeSwarmID,
DataSource: "runtime_status_error",
HTTPStatus: status,
ErrorMessage: truncateAgnetFailureReason(err.Error()),
Warnings: []string{"runtime_status_query_failed"},
CheckedAt: agnetNow(),
}
}
return buildAgnetRuntimeDiagnostics(record, data, status, "runtime_status")
}
func AgnetGetUserDeploymentRuntimeDiagnostics(c *gin.Context) {
record, ok := requireAuthenticatedUserAgnetDeployment(c)
if !ok {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), agnetRuntimeClientConfigForMode(agnetRuntimeModeForRecord(record)).Timeout)
defer cancel()
common.ApiSuccess(c, agnetRuntimeDiagnosticsForRecord(ctx, record))
}
func agnetRuntimeApprovalDecisionPath(cfg agnetRuntimeConfig, record agnetDeploymentRecord, approvalID string) string { func agnetRuntimeApprovalDecisionPath(cfg agnetRuntimeConfig, record agnetDeploymentRecord, approvalID string) string {
path := strings.TrimSpace(cfg.ApprovalDecisionPath) path := strings.TrimSpace(cfg.ApprovalDecisionPath)
if path == "" { if path == "" {
+27
View File
@@ -1,5 +1,12 @@
package model package model
import (
"errors"
"strings"
"gorm.io/gorm"
)
type AgnetArtifact struct { type AgnetArtifact struct {
Id int `gorm:"primaryKey" json:"id"` Id int `gorm:"primaryKey" json:"id"`
ArtifactID string `gorm:"type:varchar(128);uniqueIndex" json:"artifact_id"` ArtifactID string `gorm:"type:varchar(128);uniqueIndex" json:"artifact_id"`
@@ -64,3 +71,23 @@ func ListAgnetArtifacts(f ListAgnetArtifactsFilter) ([]AgnetArtifact, error) {
err := q.Order("created_at_ms asc, id asc").Limit(limit).Find(&items).Error err := q.Order("created_at_ms asc, id asc").Limit(limit).Find(&items).Error
return items, err return items, err
} }
func GetAgnetArtifactByDeployment(deploymentID string, artifactID string) (AgnetArtifact, bool, error) {
var row AgnetArtifact
if DB == nil {
return row, false, nil
}
deploymentID = strings.TrimSpace(deploymentID)
artifactID = strings.TrimSpace(artifactID)
if deploymentID == "" || artifactID == "" {
return row, false, nil
}
err := DB.Where("deployment_id = ? AND artifact_id = ?", deploymentID, artifactID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return row, false, nil
}
if err != nil {
return row, false, err
}
return row, true, nil
}
+2
View File
@@ -509,9 +509,11 @@ func SetApiRouter(router *gin.Engine) {
agnetApprovalRoute.POST("/user/deployments/:deployment_id/stop", controller.AgnetStopUserDeployment) agnetApprovalRoute.POST("/user/deployments/:deployment_id/stop", controller.AgnetStopUserDeployment)
agnetApprovalRoute.GET("/user/deployments/:deployment_id/logs", controller.AgnetListUserDeploymentLogs) agnetApprovalRoute.GET("/user/deployments/:deployment_id/logs", controller.AgnetListUserDeploymentLogs)
agnetApprovalRoute.GET("/user/deployments/:deployment_id/metrics", controller.AgnetGetUserDeploymentMetrics) agnetApprovalRoute.GET("/user/deployments/:deployment_id/metrics", controller.AgnetGetUserDeploymentMetrics)
agnetApprovalRoute.GET("/user/deployments/:deployment_id/runtime-diagnostics", controller.AgnetGetUserDeploymentRuntimeDiagnostics)
agnetApprovalRoute.GET("/user/deployments/:deployment_id/events", controller.AgnetListUserDeploymentEvents) agnetApprovalRoute.GET("/user/deployments/:deployment_id/events", controller.AgnetListUserDeploymentEvents)
agnetApprovalRoute.POST("/user/deployments/:deployment_id/simulate-events", controller.AgnetSimulateUserDeploymentEvents) agnetApprovalRoute.POST("/user/deployments/:deployment_id/simulate-events", controller.AgnetSimulateUserDeploymentEvents)
agnetApprovalRoute.GET("/user/deployments/:deployment_id/artifacts", controller.AgnetListUserDeploymentArtifacts) agnetApprovalRoute.GET("/user/deployments/:deployment_id/artifacts", controller.AgnetListUserDeploymentArtifacts)
agnetApprovalRoute.GET("/user/deployments/:deployment_id/artifacts/:artifact_id/content", controller.AgnetGetUserDeploymentArtifactContent)
agnetApprovalRoute.GET("/user/deployments/:deployment_id/sk-snapshots", controller.AgnetListUserSKSnapshots) agnetApprovalRoute.GET("/user/deployments/:deployment_id/sk-snapshots", controller.AgnetListUserSKSnapshots)
agnetApprovalRoute.GET("/user/deployments/:deployment_id/timeline", controller.AgnetGetUserDeploymentTimeline) agnetApprovalRoute.GET("/user/deployments/:deployment_id/timeline", controller.AgnetGetUserDeploymentTimeline)
agnetApprovalRoute.POST("/user/tasks/:task_id/deployment-draft", controller.AgnetCreateTaskDeploymentDraft) agnetApprovalRoute.POST("/user/tasks/:task_id/deployment-draft", controller.AgnetCreateTaskDeploymentDraft)
+21 -3
View File
@@ -1,9 +1,27 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="UTF-8" />
<title>Heicode Manager placeholder</title> <link rel="icon" type="image/svg+xml" href="/heicode-logo.svg?v=h-glass-2" />
</head> <link rel="icon" type="image/png" sizes="32x32" href="/logo.png?v=h-glass-2" />
<link rel="shortcut icon" href="/favicon.ico?v=h-glass-2" />
<link rel="apple-touch-icon" href="/logo.png?v=h-glass-2" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Primary Meta Tags -->
<title>Heicode Manager</title>
<meta name="title" content="Heicode Manager" />
<meta
name="description"
content="Heicode Manager — multi-tenant control plane for Agnet deployments, events and audit."
/>
<meta property="og:title" content="Heicode Manager" />
<meta property="og:image" content="/logo.png?v=h-glass-2" />
<meta property="og:type" content="website" />
<meta name="theme-color" content="#7B6BE3" />
<link rel="icon" href="/favicon.ico"><script defer src="/static/js/vendor-radix.8fa3e0a349.js"></script><script defer src="/static/js/vendor-tanstack.632dbe8908.js"></script><script defer src="/static/js/lib-react.5c8909c28c.js"></script><script defer src="/static/js/9238.45c9c35ccf.js"></script><script defer src="/static/js/index.c2989bbeef.js"></script><link href="/static/css/index.fd51d44fe8.css" rel="stylesheet"></head>
<body> <body>
<div id="root"></div> <div id="root"></div>
</body> </body>
+33
View File
@@ -179,6 +179,25 @@ export type AgnetDeployment = {
} }
} }
export type AgnetRuntimeDiagnostics = {
deployment_id: string
runtime_mode?: 'agnet' | 'swarm' | string
sub_mode?: AgnetSubMode
runtime_deployment_id?: string
runtime_swarm_id?: string
data_source?: string
http_status?: number
status?: string
phase?: string
progress?: unknown
error_message?: string
agents?: Array<Record<string, unknown>>
artifacts?: Array<Record<string, unknown>>
metrics?: Record<string, unknown>
warnings?: string[]
checked_at?: string
}
export type AgnetApprovalRequest = { export type AgnetApprovalRequest = {
approval_id: string approval_id: string
user_id: number user_id: number
@@ -360,6 +379,20 @@ export async function getAgnetDeploymentTimeline(deploymentId: string) {
) )
} }
export async function getAgnetRuntimeDiagnostics(
deploymentId: string
): Promise<AgnetRuntimeDiagnostics | null> {
const res = await api.get<ApiEnvelope<AgnetRuntimeDiagnostics>>(
`/api/agnet/user/deployments/${deploymentId}/runtime-diagnostics`,
{
skipBusinessError: true,
skipErrorHandler: true,
} as Record<string, unknown>
)
if (!res.data?.success) return null
return res.data?.data ?? null
}
export async function simulateAgnetDeploymentEvents( export async function simulateAgnetDeploymentEvents(
deploymentId: string, deploymentId: string,
events?: string[] events?: string[]
+182
View File
@@ -63,6 +63,7 @@ import { QueryState } from '@/components/query-state'
import { import {
approveAgnetApproval, approveAgnetApproval,
getAgnetDeploymentEvents, getAgnetDeploymentEvents,
getAgnetRuntimeDiagnostics,
getAgnetDeploymentTimeline, getAgnetDeploymentTimeline,
listAgnetApprovals, listAgnetApprovals,
listAgnetCredentialLeases, listAgnetCredentialLeases,
@@ -73,6 +74,7 @@ import {
type AgnetApprovalRequest, type AgnetApprovalRequest,
type AgnetCredentialLease, type AgnetCredentialLease,
type AgnetDeployment, type AgnetDeployment,
type AgnetRuntimeDiagnostics,
type AgnetRuntimeExecution, type AgnetRuntimeExecution,
type AgnetSKAccessPolicy, type AgnetSKAccessPolicy,
} from './api' } from './api'
@@ -354,6 +356,17 @@ function artifactTypeToneClass(value: unknown): string {
return 'bg-muted/40 text-muted-foreground ring-border/60' return 'bg-muted/40 text-muted-foreground ring-border/60'
} }
function isFallbackRuntimeArtifact(item: Record<string, unknown>): boolean {
const title = String(item.title || '').toLowerCase()
const summary = String(item.summary || '').toLowerCase()
const uri = String(item.uri || '').toLowerCase()
return (
title.includes('runtime execution summary') ||
summary.includes('without per-agent artifacts') ||
uri.includes('/artifacts/summary')
)
}
function isTaskFlowEvent(eventType: unknown): boolean { function isTaskFlowEvent(eventType: unknown): boolean {
const event = String(eventType || '').toLowerCase() const event = String(eventType || '').toLowerCase()
return event.startsWith('task.') || event.startsWith('handoff.') return event.startsWith('task.') || event.startsWith('handoff.')
@@ -530,6 +543,145 @@ function formatGrantStatus(
return status || t('Active') return status || t('Active')
} }
function runtimeWarningLabel(value: string, t: (key: string) => string): string {
switch (value) {
case 'runtime_agent_failed':
return t('Runtime agent failed')
case 'runtime_completed_with_failed_agents':
return t('Runtime completed with failed agents')
case 'runtime_summary_artifact_only':
return t('Only fallback summary artifact returned')
case 'runtime_zero_model_usage':
return t('Runtime reported zero model usage')
case 'runtime_status_query_failed':
return t('Runtime status query failed')
case 'runtime_not_configured':
return t('Runtime not configured')
case 'runtime_identifiers_missing':
return t('Runtime identifiers missing')
default:
return value
}
}
function runtimeModeLabel(
diagnostics: AgnetRuntimeDiagnostics | null | undefined,
t: (key: string) => string
): string {
const mode = String(diagnostics?.runtime_mode || '').toLowerCase()
if (mode === 'swarm') return t('Swarm mode')
if (mode === 'agnet') return t('Ordinary sub mode')
return mode || '—'
}
function runtimeAgentRows(
diagnostics: AgnetRuntimeDiagnostics | null | undefined
) {
return (diagnostics?.agents ?? []).slice(0, 4).map((agent, idx) => ({
id: String(agent.agent_id || agent.instance_id || idx),
role: String(agent.role || '—'),
status: String(agent.status || agent.runtime_state || '—'),
output: String(agent.output || agent.failure_reason || ''),
}))
}
function RuntimeDiagnosticsPanel({
diagnostics,
isLoading,
}: {
diagnostics?: AgnetRuntimeDiagnostics | null
isLoading: boolean
}) {
const { t } = useTranslation()
const warnings = diagnostics?.warnings ?? []
const agents = runtimeAgentRows(diagnostics)
const hasWarning = warnings.length > 0
return (
<div
className={cn(
'mt-3 rounded-xl border p-3',
hasWarning
? 'border-amber-500/35 bg-amber-500/10'
: 'border-dashed border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-background/35'
)}
>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='text-foreground flex items-center gap-2 text-xs font-semibold'>
<AlertOctagon
className={cn(
'h-3.5 w-3.5',
hasWarning ? 'text-amber-400' : 'text-muted-foreground'
)}
/>
{t('Runtime diagnostics')}
</p>
<p className='text-muted-foreground mt-1 text-xs'>
{t(
'Manager checks Runtime status separately from callback data, without mixing ordinary sub and swarm modes.'
)}
</p>
</div>
{isLoading ? (
<CircleDashed className='text-muted-foreground h-4 w-4 animate-spin' />
) : null}
</div>
<div className='mt-3 grid gap-2 sm:grid-cols-3'>
<MetaPill
icon={Rocket}
label={t('runtime mode')}
value={runtimeModeLabel(diagnostics, t)}
/>
<MetaPill
icon={Activity}
label={t('runtime status')}
value={formatStatusLabel(diagnostics?.status || '—', t)}
/>
<MetaPill
icon={FileSearch}
label={t('artifact source')}
value={String(diagnostics?.data_source || '—')}
/>
</div>
{hasWarning && (
<div className='mt-3 flex flex-wrap gap-1.5'>
{warnings.map((warning) => (
<span
key={warning}
className='rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-200 ring-1 ring-amber-500/30 ring-inset'
>
{runtimeWarningLabel(warning, t)}
</span>
))}
</div>
)}
{agents.length > 0 && (
<div className='mt-3 space-y-1.5'>
{agents.map((agent) => (
<div
key={agent.id}
className='bg-background/45 rounded-lg px-2 py-1.5 text-[11px]'
>
<div className='flex items-center justify-between gap-2'>
<span className='font-mono'>{agent.role}</span>
<StatusBadge phase={agent.status} />
</div>
{agent.output && (
<p className='text-muted-foreground mt-1 line-clamp-2'>
{agent.output}
</p>
)}
</div>
))}
</div>
)}
</div>
)
}
function RunDetailPanel({ dep }: { dep: AgnetDeployment }) { function RunDetailPanel({ dep }: { dep: AgnetDeployment }) {
const { t } = useTranslation() const { t } = useTranslation()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -538,6 +690,13 @@ function RunDetailPanel({ dep }: { dep: AgnetDeployment }) {
const phase = dep.phase || dep.status const phase = dep.phase || dep.status
const risk = describeRiskLevel(dep) const risk = describeRiskLevel(dep)
const grants = collectResourceGrants(dep) const grants = collectResourceGrants(dep)
const runtimeDiagnosticsQuery = useQuery({
queryKey: ['agnet', 'runtime-diagnostics', dep.deployment_id],
queryFn: () => getAgnetRuntimeDiagnostics(dep.deployment_id),
enabled: Boolean(dep.deployment_id),
refetchInterval: 30_000,
})
const runtimeDiagnostics = runtimeDiagnosticsQuery.data
const simulateMutation = useMutation({ const simulateMutation = useMutation({
mutationFn: () => simulateAgnetDeploymentEvents(dep.deployment_id), mutationFn: () => simulateAgnetDeploymentEvents(dep.deployment_id),
onSuccess: () => { onSuccess: () => {
@@ -650,6 +809,11 @@ function RunDetailPanel({ dep }: { dep: AgnetDeployment }) {
/> />
</div> </div>
<RuntimeDiagnosticsPanel
diagnostics={runtimeDiagnostics}
isLoading={runtimeDiagnosticsQuery.isLoading}
/>
<div className='mt-4 grid gap-3 md:grid-cols-3'> <div className='mt-4 grid gap-3 md:grid-cols-3'>
<div className='bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3 md:col-span-3'> <div className='bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3 md:col-span-3'>
<div className='flex items-center justify-between gap-3'> <div className='flex items-center justify-between gap-3'>
@@ -1309,11 +1473,29 @@ function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
<p className='text-muted-foreground mt-1 line-clamp-2 text-[11px]'> <p className='text-muted-foreground mt-1 line-clamp-2 text-[11px]'>
{String(item.summary || item.uri || '—')} {String(item.summary || item.uri || '—')}
</p> </p>
{isFallbackRuntimeArtifact(item) && (
<p className='mt-1 rounded-md bg-amber-500/10 px-2 py-1 text-[10px] text-amber-200 ring-1 ring-amber-500/25 ring-inset'>
{t(
'Fallback summary only; not a final business deliverable.'
)}
</p>
)}
{Boolean(item.uri) && ( {Boolean(item.uri) && (
<p className='text-muted-foreground mt-1 truncate font-mono text-[10px]'> <p className='text-muted-foreground mt-1 truncate font-mono text-[10px]'>
{String(item.uri)} {String(item.uri)}
</p> </p>
)} )}
{Boolean(item.artifact_id) && (
<a
href={`/api/agnet/user/deployments/${encodeURIComponent(deploymentId)}/artifacts/${encodeURIComponent(String(item.artifact_id))}/content`}
target='_blank'
rel='noreferrer'
className='text-primary mt-2 inline-flex items-center gap-1 text-[11px] font-medium hover:underline'
>
<ArrowUpRight className='h-3 w-3' />
{t('Download artifact')}
</a>
)}
</li> </li>
))} ))}
</ul> </ul>
+18
View File
@@ -3478,6 +3478,24 @@
"Task not found. It may have been removed or was never created.": "Task not found. It may have been removed or was never created.", "Task not found. It may have been removed or was never created.": "Task not found. It may have been removed or was never created.",
"Task overview": "Task overview", "Task overview": "Task overview",
"Tasks appear here after you confirm the recommendation in the desktop client and launch Agnet.": "Tasks appear here after you confirm the recommendation in the desktop client and launch Agnet.", "Tasks appear here after you confirm the recommendation in the desktop client and launch Agnet.": "Tasks appear here after you confirm the recommendation in the desktop client and launch Agnet.",
"Runtime diagnostics": "Runtime diagnostics",
"Manager checks Runtime status separately from callback data, without mixing ordinary sub and swarm modes.": "Manager checks Runtime status separately from callback data, without mixing ordinary sub and swarm modes.",
"runtime mode": "runtime mode",
"runtime status": "runtime status",
"artifact source": "artifact source",
"Runtime agent failed": "Runtime agent failed",
"Runtime completed with failed agents": "Runtime completed with failed agents",
"Only fallback summary artifact returned": "Only fallback summary artifact returned",
"Runtime reported zero model usage": "Runtime reported zero model usage",
"Runtime status query failed": "Runtime status query failed",
"Runtime not configured": "Runtime not configured",
"Runtime identifiers missing": "Runtime identifiers missing",
"Swarm mode": "Swarm mode",
"Ordinary sub mode": "Ordinary sub mode",
"Artifacts": "Artifacts",
"No artifacts yet": "No artifacts yet",
"Fallback summary only; not a final business deliverable.": "Fallback summary only; not a final business deliverable.",
"Download artifact": "Download artifact",
"Team Collaboration": "Team Collaboration", "Team Collaboration": "Team Collaboration",
"Technical Support": "Technical Support", "Technical Support": "Technical Support",
"Telegram": "Telegram", "Telegram": "Telegram",
+16
View File
@@ -3482,6 +3482,20 @@
"Task not found. It may have been removed or was never created.": "任务不存在,可能已被删除或从未创建。", "Task not found. It may have been removed or was never created.": "任务不存在,可能已被删除或从未创建。",
"Task overview": "任务总览", "Task overview": "任务总览",
"Tasks appear here after you confirm the recommendation in the desktop client and launch Agnet.": "在客户端确认推荐摘要并启动 Agnet 后,任务会出现在这里。", "Tasks appear here after you confirm the recommendation in the desktop client and launch Agnet.": "在客户端确认推荐摘要并启动 Agnet 后,任务会出现在这里。",
"Runtime diagnostics": "运行时诊断",
"Manager checks Runtime status separately from callback data, without mixing ordinary sub and swarm modes.": "Manager 会独立检查运行时状态,并明确区分普通 sub 与蜂群模式,不混用回调数据。",
"runtime mode": "运行模式",
"runtime status": "运行状态",
"artifact source": "产物来源",
"Runtime agent failed": "运行时智能体失败",
"Runtime completed with failed agents": "运行时已结束但存在失败智能体",
"Only fallback summary artifact returned": "只返回了兜底摘要产物",
"Runtime reported zero model usage": "运行时模型用量为 0",
"Runtime status query failed": "运行时状态查询失败",
"Runtime not configured": "运行时未配置",
"Runtime identifiers missing": "缺少运行时标识",
"Swarm mode": "蜂群模式",
"Ordinary sub mode": "普通 sub 模式",
"Team Collaboration": "团队协作", "Team Collaboration": "团队协作",
"Technical Support": "技术支持", "Technical Support": "技术支持",
"Telegram": "Telegram", "Telegram": "Telegram",
@@ -4148,6 +4162,8 @@
"Agent role": "智能体角色", "Agent role": "智能体角色",
"Artifacts": "产物", "Artifacts": "产物",
"No artifacts yet": "暂无产物", "No artifacts yet": "暂无产物",
"Fallback summary only; not a final business deliverable.": "仅为运行时兜底摘要,不是最终业务交付物。",
"Download artifact": "下载产物",
"SK snapshots": "SK 快照", "SK snapshots": "SK 快照",
"No SK snapshots yet": "暂无 SK 快照", "No SK snapshots yet": "暂无 SK 快照",
"Merged timeline": "合并时间线", "Merged timeline": "合并时间线",