diff --git a/docs/heicode-manager-standalone-execution-plan.md b/docs/heicode-manager-standalone-execution-plan.md index 96869b14..e709598f 100644 --- a/docs/heicode-manager-standalone-execution-plan.md +++ b/docs/heicode-manager-standalone-execution-plan.md @@ -1,6 +1,6 @@ # Heicode Manager 可独立执行任务计划 -更新时间:2026-05-27 +更新时间:2026-05-30 负责人范围:Heicode Manager 端 用途:后续开发按本文逐项执行、验收和更新状态。 @@ -47,6 +47,8 @@ | 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 接口代理下载完整产物 | ## 四、任务明细 @@ -273,6 +275,43 @@ - 服务端日志不打印请求 body 中的敏感字段。 - 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 闭环 @@ -307,6 +346,8 @@ | 4 | 文档口径清理 | | 5 | AWS/GCP 占位提示 | | 6 | 请求 body 加密策略确认 | +| 7 | Runtime 状态诊断与兜底产物识别 | +| 8 | Artifact 完整内容代理下载 | 完成批次 C 后,Manager 端应具备清晰的任务视角、持久化上下文、准确页面口径和更少误导。 @@ -356,6 +397,8 @@ Manager 独立任务完成,不等于蜂群生产闭环完成。本文完成的 | 审计 | task/deployment/correlation_id 下能聚合审计、审批、artifact、callback | | 安全 | API、日志、页面不出现明文长期密钥 | | 请求加密 | 桌面端 sub 请求支持与模型调用一致的 V2 body 加密;浏览器后台普通 JSON 兼容路径不受影响 | +| Runtime 诊断 | Manager 页面能显示 Runtime 真实状态来源、失败 Agent、兜底产物和普通 sub / 蜂群模式归属 | +| Artifact 内容 | Manager 用户态接口能代理下载 Runtime 保存的完整 artifact 内容,且不暴露存储凭据 | | 口径 | 页面和文档不把本地占位/模拟事件说成真实 Runtime | ## 八、执行时不能突破的边界 diff --git a/docs/heicode-manager-sub-swarm-progress-checklist.md b/docs/heicode-manager-sub-swarm-progress-checklist.md index 0d98b128..212ba3aa 100644 --- a/docs/heicode-manager-sub-swarm-progress-checklist.md +++ b/docs/heicode-manager-sub-swarm-progress-checklist.md @@ -1,8 +1,8 @@ -# Heicode Manager 蜂群模式进度清单 +# Heicode Manager 普通 sub 与蜂群模式进度清单 -更新时间:2026-05-28 +更新时间:2026-05-30 负责人范围:Heicode Manager 端 -用途:给负责人、上级和联调同学快速确认 Manager 端在蜂群模式下已经具备什么、还要做什么、哪些需要客户端或蜂群项目配合。 +用途:给负责人、上级和联调同学快速确认 Manager 端在普通 sub 与蜂群模式下已经具备什么、还要做什么、哪些需要客户端或 Agent Manager / 蜂群项目配合。 ## 资料来源 @@ -19,6 +19,11 @@ 蜂群模式不是普通 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 | @@ -55,10 +60,12 @@ Heicode 桌面客户端 | `/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 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 鉴权 | 支持 `X-Agnet-Service-Token` 和 HMAC 签名校验,并可从 Key Vault ref 读取签名密钥 | `heicode/controller/agnet_callback.go` | | callback 幂等 | `event_id` / `idempotency_key` 去重,重复回调返回成功但不重复写 | `heicode/model/agnet_callback.go` | | 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` | | 审批结果回传 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` | @@ -78,7 +85,9 @@ Heicode 桌面客户端 | P0 | Runtime 联调配置模板 | 已完成:两份 Agent Manager 对接任务清单已写清 `AGNET_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 | @@ -116,6 +125,7 @@ Heicode 桌面客户端 | 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 | diff --git a/docs/integration/heicode-desktop-sub-agile-api.md b/docs/integration/heicode-desktop-sub-agile-api.md index 4d249c41..500caba9 100644 --- a/docs/integration/heicode-desktop-sub-agile-api.md +++ b/docs/integration/heicode-desktop-sub-agile-api.md @@ -1,6 +1,6 @@ # Heicode 桌面客户端 sub 敏捷流程 API 对接文档 -更新时间:2026-05-28 +更新时间:2026-05-30 适用范围:Heicode Desktop / 本地服务对接 Heicode Manager,跑通普通 sub 模式敏捷开发流程。 Manager 生产地址:`https://code.xinghanlab.com` @@ -145,12 +145,82 @@ Accept: application/json 4. 当任务卡生成后,创建 deployment draft:POST /api/agnet/user/tasks/{task_id}/deployment-draft 5. 创建 Manager deployment:POST /api/agnet/user/deployments 6. 轮询 deployment detail / events / timeline -7. 展示 artifacts / sk-snapshots / logs / metrics -8. 如出现 approval,桌面端展示审批并调用 approve/reject -9. 完成后继续迭代或停止 deployment +7. 查询 runtime-diagnostics,区分 callback 已到、Runtime 状态、失败 Agent 和兜底摘要 artifact +8. 查询 artifacts 列表;如果需要完整文件,调用 artifact content 代理接口下载 +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`。 - 当前 create / callback / detail / metrics / events / timeline / stop 已真实有效;artifacts / sk-snapshots 需要 Runtime 在真实任务中回写 `artifact.created` / `sk_tool.*` 后才会有数据。 -### 3.2 桌面客户端联调结论 +### 3.4 桌面客户端联调结论 普通 sub 模式可以开始桌面客户端联调。建议先按 Windows 最新客户端跑通,因为生产已有 Windows 设备绑定记录;macOS 端必须先确认客户端版本和 Keychain 凭据。 diff --git a/heicode/controller/agnet_callback.go b/heicode/controller/agnet_callback.go index 17147e7a..222f830f 100644 --- a/heicode/controller/agnet_callback.go +++ b/heicode/controller/agnet_callback.go @@ -1,11 +1,13 @@ package controller import ( + "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "io" + "net/http" "os" "sort" "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)}) } +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 { var payload agnetCallbackEnvelope if err := common.UnmarshalJsonStr(row.PayloadJSON, &payload); err != nil { diff --git a/heicode/controller/agnet_control_plane_test.go b/heicode/controller/agnet_control_plane_test.go index 51367e82..3c8a18d1 100644 --- a/heicode/controller/agnet_control_plane_test.go +++ b/heicode/controller/agnet_control_plane_test.go @@ -397,6 +397,135 @@ func TestAgnetRuntimeShadowCreateStoresRuntimeMapping(t *testing.T) { 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) { db := setupAgnetControlPlaneTestDB(t) resetAgnetControlPlaneState(t) diff --git a/heicode/controller/agnet_runtime_client.go b/heicode/controller/agnet_runtime_client.go index 51337bfc..5a547fa1 100644 --- a/heicode/controller/agnet_runtime_client.go +++ b/heicode/controller/agnet_runtime_client.go @@ -32,6 +32,8 @@ type agnetRuntimeConfig struct { Token string CreatePath string HealthPath string + StatusPath string + ArtifactContentPath string StopPath string ApprovalDecisionPath string Timeout time.Duration @@ -44,6 +46,25 @@ type agnetRuntimeSyncResult struct { 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 { switch strings.ToLower(strings.TrimSpace(value)) { case agnetRuntimeModeSwarm: @@ -101,6 +122,8 @@ func agnetRuntimeClientConfigForMode(mode string) agnetRuntimeConfig { Token: strings.TrimSpace(common.GetEnvOrDefaultString(prefix+"SERVICE_TOKEN", "")), CreatePath: common.GetEnvOrDefaultString(prefix+"CREATE_PATH", defaultCreatePath), 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), ApprovalDecisionPath: common.GetEnvOrDefaultString(prefix+"APPROVAL_DECISION_PATH", defaultApprovalPath), Timeout: time.Duration(timeoutSec) * time.Second, @@ -505,6 +528,249 @@ func agnetRuntimeStopPathForRecord(cfg agnetRuntimeConfig, record agnetDeploymen 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 { path := strings.TrimSpace(cfg.ApprovalDecisionPath) if path == "" { diff --git a/heicode/model/agnet_artifact.go b/heicode/model/agnet_artifact.go index 540a1ea2..2a1fe453 100644 --- a/heicode/model/agnet_artifact.go +++ b/heicode/model/agnet_artifact.go @@ -1,5 +1,12 @@ package model +import ( + "errors" + "strings" + + "gorm.io/gorm" +) + type AgnetArtifact struct { Id int `gorm:"primaryKey" json:"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 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 +} diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index e528f4b7..2367355c 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -509,9 +509,11 @@ func SetApiRouter(router *gin.Engine) { agnetApprovalRoute.POST("/user/deployments/:deployment_id/stop", controller.AgnetStopUserDeployment) agnetApprovalRoute.GET("/user/deployments/:deployment_id/logs", controller.AgnetListUserDeploymentLogs) 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.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/:artifact_id/content", controller.AgnetGetUserDeploymentArtifactContent) agnetApprovalRoute.GET("/user/deployments/:deployment_id/sk-snapshots", controller.AgnetListUserSKSnapshots) agnetApprovalRoute.GET("/user/deployments/:deployment_id/timeline", controller.AgnetGetUserDeploymentTimeline) agnetApprovalRoute.POST("/user/tasks/:task_id/deployment-draft", controller.AgnetCreateTaskDeploymentDraft) diff --git a/heicode/web/default/dist/index.html b/heicode/web/default/dist/index.html index 81088411..c993d6e8 100644 --- a/heicode/web/default/dist/index.html +++ b/heicode/web/default/dist/index.html @@ -1,9 +1,27 @@
- -
+
+ {t( + 'Manager checks Runtime status separately from callback data, without mixing ordinary sub and swarm modes.' + )} +
++ {agent.output} +
+ )} +{String(item.summary || item.uri || '—')}
+ {isFallbackRuntimeArtifact(item) && ( ++ {t( + 'Fallback summary only; not a final business deliverable.' + )} +
+ )} {Boolean(item.uri) && ({String(item.uri)}
)} + {Boolean(item.artifact_id) && ( + +