feat: complete swarm manager callback loop

This commit is contained in:
gongzhiyong
2026-05-27 21:17:32 +08:00
parent 741cc0d254
commit 4ccf7b1062
13 changed files with 1336 additions and 223 deletions
@@ -1,232 +1,125 @@
# Heicode Manager sub 模式与蜂群模式进度清单
# Heicode Manager 蜂群模式进度清单
更新时间:2026-05-26
更新时间:2026-05-27
负责人范围:Heicode Manager 端
文档目的:给项目负责人、上级和联调同学快速看到当前项目流程、已完成项、未完成项、原因、依赖和下一步。
用途:给负责人、上级和联调同学快速确认 Manager 端在蜂群模式下已经具备什么、还要做什么、哪些需要客户端或蜂群项目配合。
## 资料来源
| 来源 | 用途 |
|---|---|
| `http://gitee.ath.cx:3000/taijibaga/heicode/src/branch/main/docs/product-package` | Heicode 产品资料包,定义客户端、Manager、Agnet 平台、模型、密钥、审批和交付边界 |
| `http://gitee.ath.cx:3000/taijibaga/fengqun/src/branch/main/docs` | 蜂群资料包,定义蜂群最小闭环、生产级差距、任务图、claim、handoff、artifact、审批和验收口径 |
| 当前仓库 `heicode/` 代码 | Manager 实际已实现能力核查 |
| 当前仓库 `docs/product-package/` | 本仓库内同步的 Heicode 产品资料包,用于核对远端资料包落地口径 |
| `docs/heicode-runtime-auth-newapi-secret-design.md` | 当前 Manager / Agnet / NewAPI / Azure Key Vault 的运行时边界修正文档 |
| `docs/integration/agnet-platform-request-contract.md` | Manager 到 Agnet 平台的本地最小控制面与生产联调契约 |
| `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、Agnet 平台、CodeGW、Azure Key Vault 的边界 |
| `docs/integration/heicode-desktop-sub-agile-api.md` | Heicode 桌面客户端接 Manager 的普通 sub 敏捷流程 |
| 当前仓库 `heicode/` 代码 | Manager 端实际实现核查 |
## 二次核查结论
## 一、核心边界
本清单已经按 2026-05-26 当前代码和资料包重新核查。下面结论只按代码或文档证据填写,不把规划项写成已完成。
蜂群模式不是普通 sub 敏捷/瀑布本身。普通 sub 是 Heicode 的任务组织方式;蜂群是 Agnet/Swarm Runtime 的执行方式。
| 核查项 | 确认结果 | 证据 |
|---|---|---|
| Heicode 产品资料包 | 远端资料包与本仓库 `docs/product-package/` 口径一致,当前用户侧叫“密钥保管器”,技术实现写 Azure Key Vault | `docs/product-package/README.md`、`docs/product-package/01-product-overview.md`、远端 Gitea contents API |
| 蜂群资料包 | `fengqun` 已完成的是 `swarm-minimal` 本地最小闭环,不等于生产级平台联调完成 | 远端 `MINIMAL_CLOSED_LOOP_STATUS.zh-CN.md`、`01-ten-day-delivery-plan.md` |
| 瀑布 / 敏捷与蜂群关系 | Heicode 可用瀑布/敏捷表达任务组织或阶段门;蜂群 Runtime 不能依托瀑布、敏捷、Scrum 固定流程 | `docs/heicode.md`、远端 `agnet-swarm-design-principles.md` |
| Manager Agnet 控制面 | 本仓库实现的是 `/api/agnet/deployments` 本地最小控制面,占位保存 deployment、manifest、audit、logs/metrics 空壳;未真实创建生产 Swarm Run | `heicode/controller/agnet_control_plane.go`、`docs/integration/agnet-platform-request-contract.md` |
| 用户任务入口 | 首页已通过 mcp-server 代理创建 HeicodeTask、追问和任务卡;但任务卡尚未自动转换成本地 Agnet deployment 或真实 Swarm Run | `heicode/web/default/src/lib/heicode-mcp.ts`、`heicode/web/default/src/features/tasks/task-card-view.tsx` |
| 普通用户部署权限 | 当前 `/api/agnet/deployments` 创建/查询/停止走 `AdminAuth`,不满足普通用户在 Manager 辅助控制台发起部署的产品闭环 | `heicode/router/api-router.go` |
| 平台回调 | 当前没有 `POST /api/agnet/callbacks/swarm-events`、`approval-requests`、`artifacts`、`usage`、`status` 这类服务端回调入口 | `heicode/router/api-router.go`、远端 `agnet-swarm-design-principles.md` |
| 审批能力 | Manager 有用户态 approval/lease 记录 API,但这不是 Agnet 平台服务端回调审批主线,也没有回传 Runtime 继续/停止 | `heicode/controller/agnet_approval.go` |
## 结论摘要
1. sub 模式下的瀑布 / 敏捷,和蜂群模式不是一回事。
2. sub 模式瀑布 / 敏捷属于 Heicode 的任务组织方式和用户体验层,决定任务怎么拆、怎么推进、怎么确认。
3. 蜂群模式属于 Agnet / Swarm Runtime 执行层,决定多个 Agent / worker 如何动态生成任务图、claim 任务、交接、失败恢复、回传事件和交付物。
4. Manager 侧已经完成资源、密钥、权限、任务草稿入口、任务卡展示、Agnet 部署本地占位、审批记录、短期凭证租约、审计展示等基础控制面。
5. 当前还没有完成生产级蜂群闭环:没有真实 `POST /api/swarms` 或生产 Agnet 平台创建联调、没有真实 worker runtime claim、没有 artifact / event callback 完整接收、没有客户端高危审批主体验闭环。
6. 蜂群资料包明确说明:`fengqun` 当前达到本仓库定义的最小化闭环验收,但不是生产级完整交付;生产级差距在 Kubernetes worker runtime、Manager / Agnet API、人类审批主线、生产权限和监控。
7. 当前 Manager 端最大风险不是“没有页面”,而是“页面/接口已有一部分,但仍是本地控制面、管理员权限或 mcp-server 任务草稿,尚未和生产 Swarm Runtime 打通”。
## 概念边界
| 概念 | 所属层 | 当前定位 | 不能误解为 |
| 系统 | 定位 | 应该做什么 | 不应该做什么 |
|---|---|---|---|
| sub 瀑布 | Heicode 任务组织层 | 按需求、设计、开发、测试、部署等阶段推进 | 蜂群 Runtime |
| sub 敏捷 | Heicode 任务组织层 | 小步迭代,允许多轮反馈和调整 | 蜂群 Runtime |
| 蜂群模式 | Agnet / Swarm Runtime 层 | 动态任务图、能力编队、任务池 claim、handoff、失败恢复、事件和产物回传 | 瀑布 / 敏捷 / Scrum |
| Manager | 控制面 / 辅助控制台 | 资源绑定、密钥引用、权限、启动摘要、状态、审计、审批记录 | 编码主体验或 Runtime 执行器 |
| 客户端 | 用户主体验 | 输入想法、持续推进任务、查看反馈、接收交付、高危审批 | 资源后台或模型供应商配置页 |
| 蜂群平台 | 执行层 | AKS / worker runtime、任务图、claim、事件、artifact、指标 | 用户控制台 |
| Heicode 桌面客户端 | 用户主体验 | 输入目标、持续补充需求、查看反馈、审批高危操作、接收交付结果 | 直接配置 AKS、模型供应商、完整蜂群 payload |
| Heicode Manager | 控制面和记录面 | 资源绑定、`secret_ref`、权限清单、生成启动请求、记录 deployment/swarm 映射、回调、artifact、timeline、审批、审计 | 替代客户端做主开发对话,或替代 Runtime 执行任务 |
| HeiCode-Swarm / Agnet Runtime | 执行层 | 创建 Swarm Run、任务图、Agent 编队、claim、heartbeat、handoff、执行、结果回传 | 保存长期明文密钥,直接暴露给普通用户 |
## 总体流程
## 二、目标调用链
```text
用户输入想法
-> Heicode 形成任务目标、约束和上下文
-> Manager 准备资源绑定、secret_ref、Resource Grant、审批策略和启动摘要
-> sub 模式选择任务组织方式(瀑布 / 敏捷)
-> Manager 生成 Agnet / Swarm 请求 payload
-> 蜂群平台创建 Swarm Run / 动态任务图 / 能力编队
-> 子 Agnet claim 任务、执行、handoff、失败恢复
-> 高危动作请求客户端审批
-> 审批通过后派生短期凭证
-> 蜂群平台回传状态、事件、日志、指标、artifact、用量、审计
-> Manager 展示状态和审计,客户端展示执行反馈和交付结果
Heicode 桌面客户端
-> Heicode Manager
- V2 加密请求 body
- task/deployment draft
- resource_grants / secret_ref / budget / approval_policy
-> Agnet 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 已完成项
| 模块 | 已完成内容 | 代码 / 页面证据 | 当前状态 |
|---|---|---|---|
| 登录与用户 | Manager 支持 Heicode 登录、会话、用户上下文、`sub` / user id 作为业务身份基础 | `heicode/controller/heicode_oauth.go`、`heicode/router/api-router.go` | 已完成基础能力 |
| 模型与余额 | 客户端模型列表、余额、用量基础链路已接 Manager / NewAPI | `/api/heicode/self`、模型/渠道页面 | 已完成基础能力 |
| 设备绑定 | 设备配对、撤销、重新配对、审计、真实 IP、设备列表体验已修复 | `devices` 相关接口与页面 | 已完成 |
| 任务想法入口 | 首页可通过 mcp-server 代理提交 idea,生成 HeicodeTask 并进入追问/任务卡 | `heicode/web/default/src/features/dashboard/components/home-hero.tsx`、`heicode/web/default/src/lib/heicode-mcp.ts` | 已完成任务草稿入口,未接 Agnet 部署 |
| 任务追问与任务卡 | 支持 `configuring` 追问、答题、任务卡目标/范围/自动生成/Manager 辅助入口展示 | `heicode/web/default/src/features/tasks/task-card-view.tsx` | 已完成基础体验 |
| 资源绑定 | 支持 Git / SK / 项目文档 / 云账号 / 云资源等 Resource Binding 数据结构 | `heicode/controller/resource.go`、`heicode/model/resource.go` | 已完成基础能力 |
| 资源授权 | 支持 Resource Grant,能生成 permission manifest | `/api/resource-grants`、`/api/resource-grants/manifest` | 已完成基础能力 |
| 资源撤销 | Resource Binding 和 Grant 删除改为 revoked,保留审计链路 | `DeleteResource`、`DeleteResourceGrant` | 已完成 |
| Azure Key Vault | 支持 VM Managed Identity 访问 Key Vault、PutSecret、GetSecret、健康状态检查、`azkv://...` 引用 | `heicode/controller/secret_store.go` | 已完成 Manager 侧 |
| Azure 资源发现 | Azure cloud account 绑定后可读取 Key Vault 中 SP 凭证并调用 ARM 发现资源 | `heicode/controller/azure_resource_discovery.go` | 已完成 Azure 第一阶段 |
| Agnet 角色 | 提供六角色模板:Product / Architect / Frontend / Backend / Reviewer / Ops | `heicode/controller/agnet_role_template.go` | 已完成 |
| Agnet 部署占位 | 支持创建、查询、停止 Agnet deployment,占位状态持久化到 DB;能校验 budget、runtime、resource_grants、secret_ref | `heicode/controller/agnet_control_plane.go`、`heicode/model/agnet_deployment.go` | 已完成控制面占位,非真实 Runtime |
| 权限清单展示 | 部署详情优先展示持久化 permission manifest,密钥引用脱敏 | `heicode/web/default/src/features/agnet-console/pages.tsx` | 已完成 |
| 审计事件 | Agnet audit event 持久化,不再仅内存保存;deployment events/logs 可从审计表回放 | `heicode/model/agnet_audit.go`、`AgnetListDeploymentEvents`、`AgnetListDeploymentLogs` | 已完成本地审计 |
| SK 快照解析 | 可以根据 deployment 中的 `sk_sources` 生成 SK snapshot 摘要 | `AgnetResolveSKSnapshots`、`AgnetListSKSnapshots` | 已完成内存级占位,未持久化 |
| 审批记录 | 支持 Agnet approval request 创建、审批、拒绝、过期 | `heicode/controller/agnet_approval.go` | 已完成 Manager 记录层 |
| 短期凭证租约 | 审批通过后可生成 `lease://agnet/...`,支持撤销和过期 | `heicode/model/agnet_approval.go` | 已完成 Manager 记录层 |
| 审计页面 | 可查看审批、lease、审计流水,敏感字段脱敏 | `/audit`、`AgnetAuditPage` | 已完成基础展示 |
| 密钥状态页面 | 系统设置中可看 Key Vault 配置、认证方式、可达状态 | `/api/secret-store/status` | 已完成 |
| 后台菜单 | 模型、模型部署、供应商、全部使用日志等菜单错绑已修复 | 后台菜单点击冒烟通过 | 已完成 |
| 版本与上线 | Manager 生产版本为 1.4.4,线上 VM 构建部署已完成 | `https://code.xinghanlab.com/api/status` | 已上线 |
以下只按当前仓库代码确认,不把规划项写成已完成。
## 未完成清单
| 能力 | 当前状态 | 代码证据 |
|---|---|---|
| sub 模式字段 | 已支持 `sub_mode`,默认 `agile`,校验 `agile/waterfall` | `heicode/controller/agnet_control_plane.go`、`heicode/model/agnet_deployment.go` |
| 用户态 deployment | 已有 `/api/agnet/user/deployments` 创建、查询、停止、日志、事件、指标、artifact、SK snapshot、timeline | `heicode/router/api-router.go` |
| 任务到 deployment draft | 已有 `/api/agnet/user/tasks/:task_id/deployment-draft` | `heicode/controller/agnet_task_bridge.go` |
| `/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` |
| 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` |
| 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` |
| SK snapshot 持久化 | 已有 `agnet_sk_snapshots` 模型和列表查询 | `heicode/model/agnet_sk_snapshot.go` |
| 本地模拟事件 | 已有用户态 `simulate-events`;默认模拟会写入 callback、artifact、approval、timeline 记录,用于 Manager 自测展示链路和脱敏检查 | `AgnetSimulateUserDeploymentEvents` |
| V2 body 加密 | `/api/agnet/user/*` 和 `/api/heicode-auth/*` 已支持桌面端 V2 加密 body | `heicode/middleware/auth.go` |
| 生产普通 sub 烟测 | 已验证 production create/detail/metrics/events/logs/artifacts/sk-snapshots/timeline/stop 链路 | `docs/integration/heicode-desktop-sub-agile-api.md` |
| 模块 | 未完成内容 | 没做完的原因 | 缺什么 | 归属 |
## 四、Manager 端还需要继续做的蜂群任务
| 优先级 | 任务 | 当前缺口 | 是否 Manager 可独立做 | 验收标准 |
|---|---|---|---|---|
| sub 模式字段 | 没有完整的 `sub_mode = waterfall/agile` 任务字段、接口参数、页面选择和后端持久化 | 产品允许用瀑布/敏捷表达任务组织,但蜂群 Runtime 不应绑定固定方法论;当前代码没有统一字段 | 任务模型字段、选择入口、payload 映射、验收状态 | Manager 可先做 |
| sub 任务编排 | HeicodeTask 没有自动生成 Agnet deployment payload 并启动 | mcp-server 任务卡和本地 `/api/agnet/deployments` 是两条线,尚未打通 | task -> recommendation -> resource grants -> deployment payload 的桥接层 | Manager 可先做,客户端配合验收 |
| 用户态部署权限 | 普通用户无法按产品闭环直接创建 Agnet deployment | 后端 deployment 控制面当前使用 `AdminAuth`,更像联调/管理接口 | 设计 user-scoped create/query/stop,校验 resource grants 和用户归属 | Manager 可先做 |
| API 命名统一 | 蜂群资料包使用 `POST /api/swarms`,Manager 本地契约和代码使用 `/api/agnet/deployments` | 两份资料来源处于不同层:蜂群目标平台 vs Manager 当前最小控制面 | 明确 adapter 或统一接口命名,避免联调时双方对不上 | Manager + 蜂群平台 |
| 瀑布 / 敏捷流程展示 | 客户端/Manager 还不能按瀑布或敏捷展示子阶段状态 | 缺少统一任务阶段模型和状态事件 | `stage`, `iteration`, `checkpoint`, `approval_gate` 等字段 | Manager + 客户端 |
| 真实蜂群创建 | Manager 未真实调用蜂群平台 `POST /api/swarms` 或等价接口 | 当前 `/api/agnet/deployments` 是 Manager 本地控制面占位 | 蜂群平台稳定 API、认证、请求/响应契约、联调环境 | 需要蜂群平台 |
| `swarm_id` 绑定 | Manager 未保存真实 `swarm_id`、task graph、worker 状态 | 真实 Runtime 尚未联调 | Swarm Run 模型、状态表、映射关系 | Manager + 蜂群平台 |
| 动态任务图 | 没有展示真实动态任务图、任务 claim、heartbeat、release、timeout | Manager 没有收到 Runtime 任务事件 | `swarm_tasks` / `task_graph` / `claim` / `heartbeat` 事件 | 需要蜂群平台 |
| handoff / retry | 没有真实失败、交接、重试、blocked 状态闭环 | 目前事件主要来自 Manager 审计,占位性质 | Runtime 失败注入、handoff 事件、重试策略 | 需要蜂群平台 |
| artifact 回传 | 没有 artifact 接收表、回调接口和交付物页面闭环 | 当前没有蜂群平台 artifact callback | artifact schema、存储位置、回调签名、展示页面 | Manager 可建接口,需蜂群输出 |
| 真实日志流 | 没有生产级实时日志流,当前 logs 主要是脱敏审计占位 | 蜂群平台日志接口/SSE 未接入 | `/logs/stream` 或事件流、脱敏规则、保留策略 | 需要蜂群平台 |
| 真实指标 | Metrics 仍是空/估算,不是 Runtime CPU、内存、耗时、token、成本 | 缺少 Runtime 指标源 | Prometheus/监控接口、指标字段、correlation_id | 需要蜂群平台/基础设施 |
| callback 接收 | Manager 缺少统一 `swarm-events`、`artifacts`、`approval-requests`、`usage`、`status` callback 接收端 | 目前是 Manager 拉取/本地记录,不是平台服务端回调;approval API 也需要用户认证,不适合 Runtime 直接调用 | 回调 URL、签名验证、幂等 key、重复回调处理、服务身份认证 | Manager 可先做,需蜂群调用 |
| 客户端审批主体验 | 高危审批现在是 Manager API/后台页面能力,不是客户端主审批弹窗 | 产品文档要求客户端审批,Manager 不替代客户端 | 客户端审批 UI、push/轮询、approval_id 回传 | 需要客户端 |
| 审批回传蜂群 | 审批通过/拒绝后未真实通知 Runtime 继续或停止 | 蜂群平台审批等待状态机未联调 | approval result callback/API | Manager + 蜂群平台 |
| 短期凭证注入 | Manager 只有 `lease://agnet/...` 记录,不负责真实派生/注入子 Agnet runtime | 真实凭证派生与注入应在受控 Runtime/平台完成 | Key Vault 读取策略、最小权限临时凭证、runtime 注入通道 | 需要蜂群平台/基础设施 |
| SK 快照持久化 | SK snapshot 当前存内存,容器重启会丢 | `agnetSnapshots` 是内存 map,没有对应 DB 表 | snapshot 表、artifact/source 版本、查询分页 | Manager 可先做 |
| SK 工具调用展示 | 没有真实“调用哪个 SK、产出什么、失败原因”的任务视角展示 | 当前只有 SK source / snapshot 占位 | SK invocation event、tool output summary、失败事件 | 需要蜂群/SK 平台 |
| 子 Agnet 用量归属 | 模型用量没有按 task / deployment / role / sub agent 聚合展示 | NewAPI 用量和蜂群任务上下文尚未打通 | request metadata、correlation_id、role、task_id | Manager + NewAPI + 蜂群 |
| 交付结果回流 | 客户端/Manager 没有完整交付物、测试结果、部署链接展示 | Runtime 未产出正式 artifact/result schema | delivery result schema、artifact 存储、客户端展示 | Manager + 客户端 + 蜂群 |
| AWS/GCP | 云资源绑定目前主要做 Azure,AWS/GCP 未实现 | 当前优先级先做 Azure Key Vault / ARM | AWS/GCP 授权、发现、secret_ref、权限映射 | Manager 后续 |
| 历史文档口径 | `docs/heicode.md` 仍保留 Vault/Secret Provider 历史选项,新产品资料包和运行时设计已锁定 Azure Key Vault | 新旧文档有历史分层,容易被误读为还要接 OpenBao/Vault | 保留用户侧“密钥保管器”,实现侧统一写 Azure Key Vault;清理旧优先级表述 | Manager/产品文档可改 |
| P0 | 把 `/api/swarms` adapter 文档化并固定字段 | 代码已有入口,但文档要明确它和 Runtime `POST /api/swarms` / `/api/agnet/deployments` 的映射 | 是 | 文档写清 request/response、source、`deployment_id <-> swarm_id` 映射 |
| P0 | 增加 Swarm Run 显示字段 | 页面需要明确展示 `runtime_deployment_id`、`runtime_swarm_id`、`runtime_state`、source | 是 | 用户态 deployment 详情和后台页面能看到 Runtime 映射 |
| P0 | 回调事件类型收敛 | 目前统一 `swarm-events` 已能接,但需要按蜂群事件标准列出必填字段和展示规则 | 是 | `task.created/claimed/running/completed/failed/handoff/approval/artifact` 都有 schema |
| P0 | Runtime 联调配置模板 | 需要把 `AGNET_RUNTIME_*` 配置、token、callback URL、签名方式整理给蜂群侧 | 是 | 蜂群项目按模板能调用 Manager callback |
| P1 | 审批结果回传 Runtime 联调 | Manager adapter 已有;仍需要 Runtime 提供接收接口并验证状态继续/停止 | 需要 Runtime 接口 | 审批通过/拒绝后 Runtime 状态能继续或停止 |
| P1 | Artifact 展示优化 | 已能在页面展示 artifact 类型和 URI;仍需等 Runtime 输出真实 code_patch/document/test_report/deployment_manifest | 需要 Runtime 数据 | artifact 页面/详情能按类型展示摘要和链接 |
| P1 | 任务图/Agent 状态展示占位 | Manager 还没有真实 task graph/claim/heartbeat 页面结构 | Manager 可先做展示结构,真实数据需 Runtime | 有空态和字段,不宣称真实已运行 |
| P1 | 日志/指标真实来源标识 | deployment 详情、timeline 已强化 runtime/simulated 来源;logs/metrics 仍需 Runtime 真实数据 | 需要 Runtime 数据 | 页面和 API 响应能区分来源 |
| P1 | 高危审批客户端联动文档 | Manager 有 API,桌面客户端需要知道如何轮询/展示/提交审批 | 是 | 客户端文档补齐 approval flow |
| P2 | 蜂群模式验收脚本 | Manager 已有默认模拟链路和定向单测;还需要整理生产 curl 脚本 | 是 | 本地/生产能跑出 callback、artifact、approval、timeline 可见 |
## 按归属拆分
## 五、需要蜂群项目配合的事项
### Manager 可以独立继续做
| 事项 | 说明 | 优先级 |
| 事项 | 为什么 Manager 不能单独完成 | 蜂群侧需要提供 |
|---|---|---|
| 增加 sub 模式字段 | `waterfall` / `agile` 存到任务或部署计划中 | P0 |
| 打通 idea task 到 Agnet deployment | 从任务生成推荐角色、资源、预算、风险、payload | P0 |
| 增加用户态 Agnet deployment API | 在 `UserAuth` 下允许用户创建/查询/停止自己资源范围内的 deployment | P0 |
| callback 接收端骨架 | 先实现事件、artifact、approval-request 回调接口和幂等保存 | P0 |
| artifact 数据模型 | 先建 artifact 表、列表 API、详情展示 | P0 |
| 统一 `/api/swarms` 与 `/api/agnet/deployments` 边界 | 明确 Manager 对外给 Runtime 的生产接口,保留本地 control-plane 兼容层 | P0 |
| 持久化 SK snapshot | 去掉仅内存保存,保证重启后审计和任务上下文不丢 | P1 |
| 任务视角审计聚合 | 按 task/deployment/correlation_id 聚合日志、审批、资源、用量 | P1 |
| 文档口径同步 Azure Key Vault | 清理旧 Vault / Secret Provider 优先级表述,避免被误读为还要接 OpenBao/Vault | P1 |
| AWS/GCP 占位与提示 | 保持 UI 明确“即将支持”,避免误导 | P2 |
| 真实 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/客户端审批 | approval request 和 approval decision API |
### 需要客户端配合
## 六、需要桌面客户端配合的事项
| 事项 | Manager 需要提供 | 客户端需要提供 |
| 事项 | Manager 已有基础 | 客户端需要做 |
|---|---|---|
| 高危审批主体验 | approval list/detail/approve/reject API、风险摘要、TTL、resource scope | 审批弹窗、用户确认、拒绝、结果回传 |
| 任务驾驶舱 | task status、stage、event、artifact、delivery API | 持续任务对话、状态展示、交付接收 |
| sub 瀑布/敏捷展示 | stage/iteration/checkpoint 状态数据 | 按模式展示进度和下一步 |
| 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 |
### 需要蜂群 / Agnet Runtime 配合
## 七、当前不应误报为完成的项
| 事项 | Manager 需要 | 蜂群平台需要 |
|---|---|---|
| 真实创建 Swarm Run | 创建请求、secret_ref、Resource Grant、预算、审批策略 | `swarm_id`、初始任务图、能力编队、状态返回 |
| 任务 claim / heartbeat | 状态展示、事件保存 | worker claim、heartbeat、release、timeout |
| handoff / retry / blocked | 展示失败和后续动作 | 失败恢复、任务交接、重试上限 |
| event / artifact callback | 回调接收端和幂等处理 | 事件、产物、日志摘要回调 |
| 真实日志 / 指标 | 查询和展示 | 日志流、指标源、脱敏和保留 |
| 短期凭证注入 | 审批记录、lease 引用、secret_ref | 读取/派生短期凭证并注入子 Agnet |
### 需要基础设施配合
| 事项 | 依赖 |
| 项 | 当前真实状态 |
|---|---|
| Kubernetes worker runtime | AKS/集群、worker 镜像、服务账号、网络策略 |
| 监控指标 | Prometheus 或等价指标源、日志采集、告警规则 |
| Key Vault 网络安全 | Private Endpoint / 防火墙 / Managed Identity / Workload Identity |
| 生产级回调安全 | 内网访问、签名验证、重放保护、request id / correlation id |
| 蜂群生产闭环 | 未完成。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 回调 |
| 高危审批在 Manager 里点完就闭环 | 不是。产品要求桌面客户端主审批,并且 Runtime 要收到 decision |
## 当前风险
## 八、后续执行顺序
| 风险 | 影响 | 应对 |
|---|---|---|
| 把蜂群最小闭环误报为生产级完成 | 上级预期错误,联调时暴露落差 | 文档明确“最小闭环已验收,不等于生产级完整交付” |
| 把瀑布/敏捷和蜂群模式混为一谈 | 产品和技术边界混乱 | sub 模式只表达任务组织,蜂群只表达执行层 |
| Manager 继续堆本地占位接口 | 看起来页面有了,但真实 Runtime 没跑 | 所有占位接口在文档和页面中标清“control-plane placeholder” |
| 客户端审批未接入 | 高危操作不符合产品边界 | Manager 只保留记录/API,最终审批必须走客户端 |
| 历史 Vault / Azure Key Vault 口径混用 | 部署和安全沟通混乱 | 统一用户侧叫“密钥保管器”,当前实现侧写 Azure Key Vault |
| 日志/指标无真实来源 | 无法验收蜂群运行质量 | 等 Runtime 接入后按 correlation_id 验证事件、日志、指标、artifact |
## 下一步建议
### 5/27-5/28:全链路联调与问题收敛
| 任务 | 目标 | 验收标准 |
|---|---|---|
| sub 模式字段落地 | 明确任务采用瀑布或敏捷组织方式 | payload / DB / UI 至少有一个可追溯字段 |
| idea -> deployment 桥接 | 从用户想法生成 Agnet deployment 请求 | 不再只手动建部署;任务能关联 deployment_id |
| 用户态部署权限 | 普通用户可在自己资源范围内创建/查看 deployment | 非管理员用户不 403,且不能越权看别人 deployment |
| callback 骨架 | Manager 能接事件和 artifact 回调 | 本地模拟回调可保存、去重、展示 |
| 客户端审批接口联调 | 客户端能读 pending approval 并回传决定 | approval approve/reject 后状态正确 |
### 5/29-5/30:全面测试、验收与上线风险确认
| 任务 | 目标 | 验收标准 |
|---|---|---|
| 真实蜂群接口联调 | Manager 调用蜂群平台创建 Swarm Run | 获得真实 `swarm_id`,状态可查 |
| 任务 claim 可见 | Runtime 任务领取状态回到 Manager | 能看到 created -> claimed -> running |
| artifact / event 可见 | 执行结果和事件回到 Manager | 至少一个 task.completed 和 artifact 摘要 |
| 安全验收 | 密钥不明文,审批有记录,lease 可撤销 | 日志、页面、API 响应均无明文密钥 |
| 最终风险报告 | 明确上线可放行与阻塞项 | 剩余问题按 Manager / 客户端 / 蜂群 / 基础设施分类 |
## 验收清单
| 验收项 | 当前状态 | 通过标准 |
|---|---|---|
| Manager 能保存资源和 `secret_ref` | 已完成 | 资源响应不含明文密钥 |
| Manager 能生成 Resource Grant manifest | 已完成 | manifest 只含授权范围和 `secret_ref` |
| Manager 能创建 Agnet deployment | 已完成占位 | 生产级需真实调用蜂群平台 |
| 普通用户创建 deployment | 未完成 | `UserAuth` 用户能基于自己的 Resource Grant 创建,不要求管理员权限 |
| Manager 能展示审计事件 | 已完成基础 | 生产级需包含 Runtime 事件 |
| Manager 能记录审批和 lease | 已完成记录层 | 生产级需客户端审批和 Runtime 继续/停止 |
| sub 瀑布/敏捷模式 | 未完成 | 有模式字段、状态流和页面展示 |
| `/api/swarms` / `/api/agnet/deployments` 口径 | 未完成 | Manager 与蜂群平台统一创建接口或有明确 adapter |
| 真实 Swarm Run | 未完成 | Manager 获得真实 `swarm_id` |
| 任务 claim | 未完成 | 至少一个任务从 created 到 claimed/running |
| handoff / retry | 未完成 | 失败任务可回流或转交,并可观察 |
| artifact 回传 | 未完成 | Manager/客户端可看到交付物摘要 |
| 日志/指标 | 未完成 | 可按 correlation_id 查询日志、指标、用量 |
| 客户端高危审批 | 未完成 | 客户端审批后 Manager 和 Runtime 状态一致 |
## 代码索引
| 能力 | 主要文件 |
|---|---|
| Agnet 控制面占位 | `heicode/controller/agnet_control_plane.go` |
| Agnet 审批和 lease | `heicode/controller/agnet_approval.go`、`heicode/model/agnet_approval.go` |
| Agnet 审计 | `heicode/model/agnet_audit.go` |
| 资源绑定和授权 | `heicode/controller/resource.go`、`heicode/model/resource.go` |
| Azure Key Vault | `heicode/controller/secret_store.go` |
| Azure 资源发现 | `heicode/controller/azure_resource_discovery.go` |
| 前端 Agnet 控制台 | `heicode/web/default/src/features/agnet-console/` |
| 任务想法入口 | `heicode/web/default/src/features/dashboard/components/home-hero.tsx` |
| Heicode task / mcp client | `heicode/web/default/src/lib/heicode-mcp.ts` |
| 顺序 | 任务 | 负责人范围 | 备注 |
|---:|---|---|---|
| 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 | 补页面来源标识和任务图空态 | Manager | 防止把 simulated/control-plane 误认为 runtime |
+112
View File
@@ -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` 默认链路也能写入 callback、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/agnet/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 | 当前没有真实 task graph 表和状态展示 | 不满足 | 需要 Runtime 产出 task graph/claim/heartbeat 事件 | 蜂群 + Manager |
| 展示 handoff、blocked、retry | callback 可接任意 event,但没有专门展示和字段约束 | 部分满足 | 需要标准事件 schema 和 Runtime 实际事件 | 蜂群 + Manager |
| 高危审批请求进入 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 可接 callback,但没有真实数据 | 蜂群侧定义并回调 `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 展示 | 可接通用 callback,但页面未专门展示 | 先定事件 schema,再补 timeline 展示 |
| 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:完善项
| 缺失项 | 当前状态 | 处理建议 |
|---|---|---|
| 蜂群验收脚本 | Manager 默认模拟和单测已覆盖 callback/artifact/approval/timeline;生产 curl 脚本仍需整理 | 增加一套可给联调方直接执行的 curl 脚本 |
| 页面术语统一 | 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,403 @@
# Agent Manager / HeiCode-Swarm 蜂群对接改造请求
更新时间:2026-05-27
发起方:Heicode Manager
用途:给 Agent Manager / HeiCode-Swarm 侧确认需要补齐或对齐的生产接口,便于 Heicode 桌面客户端 -> Manager -> 蜂群 Runtime 跑完整蜂群流程。
## 一、当前 Manager 已准备好的能力
| 能力 | Manager 状态 |
|---|---|
| 用户态创建 | `POST /api/agnet/user/deployments`、`POST /api/swarms` 已可创建 Manager deployment |
| Runtime 创建桥接 | 可通过 `AGNET_RUNTIME_BASE_URL` + `AGNET_RUNTIME_CREATE_PATH` 调用 Runtime |
| Runtime 停止桥接 | 可通过 `AGNET_RUNTIME_STOP_PATH` 停止 Runtime deployment |
| 回调接收 | `POST /api/agnet/callbacks/swarm-events` 已支持事件、artifact、approval、timeline 回调 |
| 回调安全 | 支持 `X-Agnet-Service-Token` 或 `X-Agnet-Signature` HMAC |
| 幂等 | `event_id` / `idempotency_key` 去重 |
| 审批结果回传 | 用户 approve/reject 后,Manager 可调用 Runtime approval decision 接口 |
| 密钥边界 | Manager 只传 `secret_ref` / `credential_ref`,不传长期明文密钥 |
## 二、Runtime 需要提供的接口
### 1. 健康检查
```http
GET /api/agnet/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
AGNET_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/agnet/callbacks/swarm-events",
"signing_secret_ref": "azkv://heicode-kv.vault.azure.net/secrets/agnet-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/agnet/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
AGNET_RUNTIME_STOP_PATH=/api/swarms/{swarm_id}/stop
```
## 三、Runtime 回调 Manager
统一回调入口:
```http
POST https://code.xinghanlab.com/api/agnet/callbacks/swarm-events
X-Agnet-Service-Token: <callback_token>
X-Agnet-Event-Id: <event_id>
X-Correlation-ID: <correlation_id>
Content-Type: application/json
```
也支持 HMAC:
```http
X-Agnet-Timestamp: <unix_ms>
X-Agnet-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
AGNET_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://agnet/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://agnet/...`。
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 `AGNET_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/agnet/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、私钥、连接串。
@@ -1221,7 +1221,7 @@ setInterval(async () => {
## 15. 当前生产注意事项
1. `https://code.xinghanlab.com` 的 Manager 用户态接口已上线,当前生产版本为 `1.4.7`。
1. `https://code.xinghanlab.com` 的 Manager 用户态接口已上线,当前生产版本为 `1.4.8`。
2. Manager 本地控制面可创建 `sub_mode=agile/waterfall` deployment。
3. 生产 Manager 已配置 Agent Manager Runtime,当前直接走 `http://20.212.121.126`;域名和 HTTPS 后续单独处理,不作为客户端当前接入阻塞项。
4. V2 加密 `deployment-draft` 已在生产验证通过:真实构造 `Content-Encoding: heicode-aead-v1` 请求返回 200,`sub_mode=agile`,`user_id=22`。
@@ -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
AGNET_RUNTIME_ENABLED=true
AGNET_RUNTIME_ASYNC=false
AGNET_RUNTIME_BASE_URL=http://<runtime-host>
AGNET_RUNTIME_SERVICE_TOKEN=<service-token>
AGNET_RUNTIME_HEALTH_PATH=/api/agnet/health
AGNET_RUNTIME_CREATE_PATH=/api/swarms
AGNET_RUNTIME_STOP_PATH=/api/swarms/{swarm_id}/stop
AGNET_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
AGNET_RUNTIME_TIMEOUT_SECONDS=10
HEICODE_PUBLIC_BASE_URL=https://code.xinghanlab.com
AGNET_RUNTIME_CALLBACK_URL=https://code.xinghanlab.com/api/agnet/callbacks/swarm-events
AGNET_CALLBACK_TOKEN=<callback-token>
```
说明:
1. `AGNET_RUNTIME_SERVICE_TOKEN` 是 Manager 调 Runtime 的 Bearer token。
2. `AGNET_CALLBACK_TOKEN` 是 Runtime 回调 Manager 时放在 `X-Agnet-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
AGNET_RUNTIME_ENABLED=true
AGNET_RUNTIME_ASYNC=false
AGNET_RUNTIME_BASE_URL=http://52.139.240.116:8000
AGNET_RUNTIME_SERVICE_TOKEN=<optional-if-supported>
AGNET_RUNTIME_HEALTH_PATH=/health
AGNET_RUNTIME_CREATE_PATH=/tasks
AGNET_RUNTIME_STOP_PATH=/tasks/{runtime_deployment_id}/stop
AGNET_RUNTIME_APPROVAL_DECISION_PATH=/api/swarms/{swarm_id}/approvals/{approval_id}
AGNET_RUNTIME_TIMEOUT_SECONDS=10
HEICODE_PUBLIC_BASE_URL=https://code.xinghanlab.com
AGNET_RUNTIME_CALLBACK_URL=https://code.xinghanlab.com/api/agnet/callbacks/swarm-events
AGNET_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/agnet/callbacks/swarm-events
X-Agnet-Service-Token: <callback-token>
X-Agnet-Event-Id: <event-id>
X-Correlation-ID: <correlation-id>
Content-Type: application/json
```
也支持 HMAC:
```http
X-Agnet-Event-Id: <event-id>
X-Agnet-Timestamp: <unix-ms>
X-Agnet-Signature: sha256=<hex>
X-Correlation-ID: <correlation-id>
Content-Type: application/json
```
签名内容:
```text
timestamp + "." + event_id + "." + raw_body
```
## 四、最小联调命令
健康检查:
```bash
curl -sS "$AGNET_RUNTIME_BASE_URL$AGNET_RUNTIME_HEALTH_PATH"
```
Manager 本地默认模拟链路:
```bash
curl -sS -X POST "https://code.xinghanlab.com/api/agnet/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/agnet/callbacks/swarm-events" \
-H "Content-Type: application/json" \
-H "X-Agnet-Service-Token: $AGNET_CALLBACK_TOKEN" \
-H "X-Agnet-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 -1
View File
@@ -1 +1 @@
1.4.7
1.4.8
+2
View File
@@ -244,6 +244,7 @@ func ApproveAgnetApprovalRequest(c *gin.Context) {
if lease != nil {
recordAgnetApprovalAudit("credential_lease.created", &approval, lease, "ok", "")
}
syncAgnetRuntimeApprovalDecision(c, &approval, lease, agnetApprovalStatusApproved)
common.ApiSuccess(c, agnetApprovalToResponse(approval, lease))
}
@@ -272,6 +273,7 @@ func RejectAgnetApprovalRequest(c *gin.Context) {
return
}
recordAgnetApprovalAudit("approval.rejected", &approval, nil, "ok", "")
syncAgnetRuntimeApprovalDecision(c, &approval, nil, agnetApprovalStatusRejected)
common.ApiSuccess(c, agnetApprovalToResponse(approval, nil))
}
+66
View File
@@ -3,6 +3,7 @@ package controller
import (
"bytes"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -33,6 +34,7 @@ func setupAgnetApprovalTestDB(t *testing.T) *gorm.DB {
&model.AgnetApprovalRequest{},
&model.AgnetCredentialLease{},
&model.AgnetAuditEvent{},
&model.AgnetDeployment{},
))
t.Cleanup(func() {
sqlDB, err := db.DB()
@@ -43,6 +45,70 @@ func setupAgnetApprovalTestDB(t *testing.T) *gorm.DB {
return db
}
func TestAgnetApprovalApproveNotifiesRuntimeDecision(t *testing.T) {
db := setupAgnetApprovalTestDB(t)
var runtimeBody string
var runtimeAuth string
var runtimePath string
runtimeServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
runtimePath = r.URL.Path
runtimeAuth = r.Header.Get("Authorization")
body, _ := io.ReadAll(r.Body)
runtimeBody = string(body)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"success":true,"data":{"status":"accepted"}}`))
}))
defer runtimeServer.Close()
t.Setenv("AGNET_RUNTIME_ENABLED", "true")
t.Setenv("AGNET_RUNTIME_BASE_URL", runtimeServer.URL)
t.Setenv("AGNET_RUNTIME_SERVICE_TOKEN", "runtime-token")
t.Setenv("AGNET_RUNTIME_APPROVAL_DECISION_PATH", "/api/swarms/{swarm_id}/approvals/{approval_id}")
require.NoError(t, db.Create(&model.AgnetDeployment{
DeploymentID: "dep_runtime_approval",
UserID: "7",
BindingScope: "project-alpha",
CorrelationID: "corr-runtime-approval",
RuntimeDeploymentID: "runtime-dep-1",
RuntimeSwarmID: "swarm-runtime-1",
Status: "running",
RuntimeState: "running",
PlanJSON: `{"user_context":{"user_id":"7"},"metadata":{"correlation_id":"corr-runtime-approval"}}`,
}).Error)
body := `{
"deployment_id":"dep_runtime_approval",
"binding_scope":"project-alpha",
"operation":"write_repository",
"resource_id":"repo-main",
"resource_type":"git",
"resource_scope":"https://example.invalid/acme/repo#main",
"target_role":"backend",
"risk_level":"high",
"requires_credential":true,
"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/repo-main",
"ttl_seconds":600
}`
_, approvalID := createAgnetApprovalForTest(t, 7, body)
w := performAgnetApprovalRequest(ApproveAgnetApprovalRequest, 7, http.MethodPost, "/approvals/:approval_id/approve", "/approvals/"+approvalID+"/approve", `{"reason":"允许写入"}`)
require.Equal(t, http.StatusOK, w.Code)
require.Equal(t, "/api/swarms/swarm-runtime-1/approvals/"+approvalID, runtimePath)
require.Equal(t, "Bearer runtime-token", runtimeAuth)
require.Contains(t, runtimeBody, `"decision":"approved"`)
require.Contains(t, runtimeBody, `"approval_id":"`+approvalID+`"`)
require.Contains(t, runtimeBody, `"manager_deployment_id":"dep_runtime_approval"`)
require.Contains(t, runtimeBody, `"runtime_deployment_id":"runtime-dep-1"`)
require.Contains(t, runtimeBody, `"swarm_id":"swarm-runtime-1"`)
require.Contains(t, runtimeBody, `"credential_ref":"lease://agnet/`)
require.NotContains(t, runtimeBody, "azkv://")
var auditRows []model.AgnetAuditEvent
require.NoError(t, db.Where("deployment_id = ?", "dep_runtime_approval").Order("id asc").Find(&auditRows).Error)
require.NotEmpty(t, auditRows)
require.Equal(t, "runtime.approval_decision.accepted", auditRows[len(auditRows)-1].Event)
}
func performAgnetApprovalRequest(handler gin.HandlerFunc, userID int, method string, routePath string, requestPath string, body string) *httptest.ResponseRecorder {
r := gin.New()
r.Handle(method, routePath, func(c *gin.Context) {
+195
View File
@@ -1311,6 +1311,194 @@ func normalizeAgnetSimulationEvents(values []string) []string {
return events
}
func firstAgnetSimulationGrant(record agnetDeploymentRecord) agnetResourceGrant {
for _, agent := range record.Plan.Agents {
for _, grant := range agent.ResourceGrants {
if strings.TrimSpace(grant.SecretRef) != "" {
return grant
}
}
}
for _, grant := range record.Plan.ResourceGrants {
if strings.TrimSpace(grant.SecretRef) != "" {
return grant
}
}
for _, agent := range record.Plan.Agents {
if len(agent.ResourceGrants) > 0 {
return agent.ResourceGrants[0]
}
}
if len(record.Plan.ResourceGrants) > 0 {
return record.Plan.ResourceGrants[0]
}
return agnetResourceGrant{}
}
func firstAgnetSimulationRole(record agnetDeploymentRecord) string {
for _, agent := range record.Plan.Agents {
if strings.TrimSpace(agent.RoleTemplate) != "" {
return strings.TrimSpace(agent.RoleTemplate)
}
}
for _, agent := range record.Plan.AgentRuntime.Agents {
if strings.TrimSpace(agent.Role) != "" {
return strings.TrimSpace(agent.Role)
}
}
return "runtime"
}
func persistAgnetSimulatedCallback(record agnetDeploymentRecord, payload agnetCallbackEnvelope) error {
if strings.TrimSpace(payload.EventID) == "" {
payload.EventID = "evt-sim-" + common.GetUUID()[:12]
}
if strings.TrimSpace(payload.IdempotencyKey) == "" {
payload.IdempotencyKey = payload.EventID
}
if strings.TrimSpace(payload.DeploymentID) == "" {
payload.DeploymentID = record.DeploymentID
}
if strings.TrimSpace(payload.SwarmID) == "" {
payload.SwarmID = firstNonEmpty(record.RuntimeSwarmID, "sim-"+record.DeploymentID)
}
if strings.TrimSpace(payload.CorrelationID) == "" {
payload.CorrelationID = record.Plan.Metadata.CorrelationID
}
if strings.TrimSpace(payload.Source) == "" {
payload.Source = "agnet-simulator"
}
if strings.TrimSpace(payload.OccurredAt) == "" {
payload.OccurredAt = agnetNow()
}
payloadJSON, _ := common.Marshal(payload)
inserted, err := model.InsertAgnetCallbackEvent(&model.AgnetCallbackEvent{
EventID: strings.TrimSpace(payload.EventID),
IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey),
CallbackType: "swarm-event",
EventType: strings.TrimSpace(payload.EventType),
DeploymentID: strings.TrimSpace(payload.DeploymentID),
SwarmID: strings.TrimSpace(payload.SwarmID),
AgentInstanceID: strings.TrimSpace(payload.AgentInstanceID),
TaskID: strings.TrimSpace(payload.TaskID),
UserID: record.Plan.UserContext.UserID,
BindingScope: firstPlanBindingScope(record.Plan),
CorrelationID: strings.TrimSpace(payload.CorrelationID),
Source: strings.TrimSpace(payload.Source),
Result: "simulated",
PayloadJSON: string(payloadJSON),
OccurredAt: strings.TrimSpace(payload.OccurredAt),
CreatedAtMs: time.Now().UnixMilli(),
})
if err != nil {
return err
}
if !inserted {
return nil
}
if err := persistAgnetArtifactFromCallback(payload, record); err != nil {
return err
}
if err := persistAgnetApprovalFromCallback(payload, record); err != nil {
return err
}
recordAgnetAuditEvent(agnetEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: "callback." + payload.EventType,
SchemaVersion: 1,
UserID: record.Plan.UserContext.UserID,
ChannelID: record.Plan.UserContext.ChannelID,
BindingScope: firstPlanBindingScope(record.Plan),
DeploymentID: record.DeploymentID,
CorrelationID: payload.CorrelationID,
OccurredAt: payload.OccurredAt,
}, "agnet_simulator", record.DeploymentID, "", "simulated")
return nil
}
func persistAgnetDefaultSimulationCallbacks(record agnetDeploymentRecord) error {
role := firstAgnetSimulationRole(record)
grant := firstAgnetSimulationGrant(record)
taskID := "task-sim-" + sanitizeAgnetRef(record.DeploymentID)
agentID := "agi-sim-" + sanitizeAgnetRef(role)
base := func(eventType string, suffix string, payload map[string]any) agnetCallbackEnvelope {
return agnetCallbackEnvelope{
EventID: "evt-sim-" + sanitizeAgnetRef(record.DeploymentID) + "-" + suffix,
IdempotencyKey: "idem-sim-" + sanitizeAgnetRef(record.DeploymentID) + "-" + suffix,
EventType: eventType,
DeploymentID: record.DeploymentID,
SwarmID: firstNonEmpty(record.RuntimeSwarmID, "sim-"+record.DeploymentID),
AgentInstanceID: agentID,
TaskID: taskID,
CorrelationID: record.Plan.Metadata.CorrelationID,
Source: "agnet-simulator",
OccurredAt: agnetNow(),
Payload: payload,
}
}
callbacks := []agnetCallbackEnvelope{
base("task.claimed", "task-claimed", map[string]any{
"title": "Simulated task claimed",
"summary": "Manager simulator marked a child Agnet task as claimed",
"agent_role": role,
"severity": "info",
}),
base("task.completed", "task-completed", map[string]any{
"title": "Simulated task completed",
"summary": "Manager simulator marked the child Agnet task as completed",
"agent_role": role,
"severity": "success",
}),
}
artifact := base("artifact.created", "artifact", map[string]any{
"title": "Simulated test report",
"summary": "Manager simulator generated a safe artifact record for smoke testing",
"checkpoint": "artifact_ready",
"severity": "success",
})
artifact.Artifact = agnetArtifactPayload{
ArtifactID: "art-sim-" + sanitizeAgnetRef(record.DeploymentID),
ArtifactType: "test_report",
Title: "Simulated test report",
Summary: "Manager simulator generated a safe artifact record for smoke testing",
URI: "artifact://simulated/" + record.DeploymentID + "/test-report",
Metadata: map[string]any{
"source": "agnet-simulator",
"redacted": true,
},
}
callbacks = append(callbacks, artifact)
if strings.TrimSpace(grant.SecretRef) != "" {
callbacks = append(callbacks, base("approval.requested", "approval", map[string]any{
"approval_id": "sim-appr-" + sanitizeAgnetRef(record.DeploymentID),
"operation": "simulation.resource_access",
"resource_id": firstNonEmpty(grant.ResourceID, record.DeploymentID),
"resource_type": firstNonEmpty(grant.ResourceType, "custom"),
"resource_scope": firstNonEmpty(agnetGrantResourceRef(grant), firstPlanBindingScope(record.Plan)),
"target_role": firstNonEmpty(grant.TargetRole, role),
"risk_level": "high",
"requires_credential": true,
"secret_ref": strings.TrimSpace(grant.SecretRef),
"ttl_seconds": 600,
"reason": "Simulated high-risk resource access for Manager smoke testing",
}))
}
callbacks = append(callbacks, base("timeline.updated", "timeline", map[string]any{
"title": "Simulated runtime timeline",
"summary": "Manager simulator generated task, artifact and approval records",
"stage": "testing",
"checkpoint": "ready_for_runtime",
"severity": "info",
"next_action": "connect_real_runtime",
}))
for _, callback := range callbacks {
if err := persistAgnetSimulatedCallback(record, callback); err != nil {
return err
}
}
return nil
}
func AgnetSimulateUserDeploymentEvents(c *gin.Context) {
if c.GetInt("role") < common.RoleAdminUser && !common.GetEnvOrDefaultBool("AGNET_SIMULATION_ENABLED", false) {
agnetError(c, "SIMULATION_DISABLED", "simulation endpoint is admin-only unless AGNET_SIMULATION_ENABLED=true")
@@ -1361,6 +1549,13 @@ func AgnetSimulateUserDeploymentEvents(c *gin.Context) {
OccurredAt: agnetNow(),
}, "agnet_simulator", record.DeploymentID, agnetRequestID(c), "simulated")
}
if len(req.Events) == 0 {
if err := persistAgnetDefaultSimulationCallbacks(record); err != nil {
common.SysLog("persistAgnetDefaultSimulationCallbacks: " + err.Error())
agnetError(c, "SIMULATION_PERSIST_FAILED", "failed to persist simulated callback records")
return
}
}
common.ApiSuccess(c, gin.H{
"deployment_id": record.DeploymentID,
@@ -778,6 +778,65 @@ func TestAgnetUserDeploymentSimulationIsDisabledForRegularUserByDefault(t *testi
require.Equal(t, "SIMULATION_DISABLED", envelope.Error.Code)
}
func TestAgnetUserDeploymentDefaultSimulationCreatesRelatedRecords(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
plan := baseAgnetResourceGrantPlan()
plan.UserContext.UserID = "7"
for idx := range plan.Agents[0].ResourceGrants {
plan.Agents[0].ResourceGrants[idx].UserID = "7"
}
createRecorder, createEnvelope := postAgnetCreateUserDeployment(t, 7, plan)
require.Equal(t, http.StatusOK, createRecorder.Code)
require.True(t, createEnvelope.Success)
var createBody map[string]any
require.NoError(t, common.Unmarshal(createRecorder.Body.Bytes(), &createBody))
deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string)
simRecorder := httptest.NewRecorder()
simCtx, _ := gin.CreateTestContext(simRecorder)
simCtx.Set("id", 7)
simCtx.Set("role", common.RoleAdminUser)
simCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
simCtx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/user/deployments/"+deploymentID+"/simulate-events", strings.NewReader(`{}`))
simCtx.Request.Header.Set("Content-Type", "application/json")
AgnetSimulateUserDeploymentEvents(simCtx)
require.Equal(t, http.StatusOK, simRecorder.Code)
require.Contains(t, simRecorder.Body.String(), `"simulated":true`)
artifactRecorder := httptest.NewRecorder()
artifactCtx, _ := gin.CreateTestContext(artifactRecorder)
artifactCtx.Set("id", 7)
artifactCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
artifactCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/user/deployments/"+deploymentID+"/artifacts", nil)
AgnetListUserDeploymentArtifacts(artifactCtx)
require.Equal(t, http.StatusOK, artifactRecorder.Code)
require.Contains(t, artifactRecorder.Body.String(), `"artifact_type":"test_report"`)
require.Contains(t, artifactRecorder.Body.String(), `"artifact_id":"art-sim-`)
approvalRecorder := httptest.NewRecorder()
approvalCtx, _ := gin.CreateTestContext(approvalRecorder)
approvalCtx.Set("id", 7)
approvalCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/approvals?deployment_id="+deploymentID+"&status=pending", nil)
ListAgnetApprovalRequests(approvalCtx)
require.Equal(t, http.StatusOK, approvalRecorder.Code)
require.Contains(t, approvalRecorder.Body.String(), `"approval_id":"sim-appr-`)
require.Contains(t, approvalRecorder.Body.String(), `"status":"pending"`)
require.NotContains(t, approvalRecorder.Body.String(), "azkv://")
timelineRecorder := httptest.NewRecorder()
timelineCtx, _ := gin.CreateTestContext(timelineRecorder)
timelineCtx.Set("id", 7)
timelineCtx.Params = gin.Params{{Key: "deployment_id", Value: deploymentID}}
timelineCtx.Request = httptest.NewRequest(http.MethodGet, "/api/agnet/user/deployments/"+deploymentID+"/timeline", nil)
AgnetGetUserDeploymentTimeline(timelineCtx)
require.Equal(t, http.StatusOK, timelineRecorder.Code)
require.Contains(t, timelineRecorder.Body.String(), `"event_type":"artifact.created"`)
require.Contains(t, timelineRecorder.Body.String(), `"event_type":"approval.requested"`)
require.Contains(t, timelineRecorder.Body.String(), `"source":"agnet-simulator"`)
}
func TestAgnetUserSwarmsAdapterCreatesScopedDeployment(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
+144 -17
View File
@@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
const (
@@ -22,14 +23,15 @@ const (
)
type agnetRuntimeConfig struct {
Enabled bool
Async bool
BaseURL string
Token string
CreatePath string
HealthPath string
StopPath string
Timeout time.Duration
Enabled bool
Async bool
BaseURL string
Token string
CreatePath string
HealthPath string
StopPath string
ApprovalDecisionPath string
Timeout time.Duration
}
type agnetRuntimeSyncResult struct {
@@ -45,14 +47,15 @@ func agnetRuntimeClientConfig() agnetRuntimeConfig {
timeoutSec = 5
}
return agnetRuntimeConfig{
Enabled: common.GetEnvOrDefaultBool("AGNET_RUNTIME_ENABLED", false),
Async: common.GetEnvOrDefaultBool("AGNET_RUNTIME_ASYNC", true),
BaseURL: strings.TrimRight(strings.TrimSpace(common.GetEnvOrDefaultString("AGNET_RUNTIME_BASE_URL", "")), "/"),
Token: strings.TrimSpace(common.GetEnvOrDefaultString("AGNET_RUNTIME_SERVICE_TOKEN", "")),
CreatePath: common.GetEnvOrDefaultString("AGNET_RUNTIME_CREATE_PATH", "/api/agnet/deployments"),
HealthPath: common.GetEnvOrDefaultString("AGNET_RUNTIME_HEALTH_PATH", "/api/agnet/health"),
StopPath: common.GetEnvOrDefaultString("AGNET_RUNTIME_STOP_PATH", "/api/agnet/deployments/{deployment_id}/stop"),
Timeout: time.Duration(timeoutSec) * time.Second,
Enabled: common.GetEnvOrDefaultBool("AGNET_RUNTIME_ENABLED", false),
Async: common.GetEnvOrDefaultBool("AGNET_RUNTIME_ASYNC", true),
BaseURL: strings.TrimRight(strings.TrimSpace(common.GetEnvOrDefaultString("AGNET_RUNTIME_BASE_URL", "")), "/"),
Token: strings.TrimSpace(common.GetEnvOrDefaultString("AGNET_RUNTIME_SERVICE_TOKEN", "")),
CreatePath: common.GetEnvOrDefaultString("AGNET_RUNTIME_CREATE_PATH", "/api/agnet/deployments"),
HealthPath: common.GetEnvOrDefaultString("AGNET_RUNTIME_HEALTH_PATH", "/api/agnet/health"),
StopPath: common.GetEnvOrDefaultString("AGNET_RUNTIME_STOP_PATH", "/api/agnet/deployments/{deployment_id}/stop"),
ApprovalDecisionPath: common.GetEnvOrDefaultString("AGNET_RUNTIME_APPROVAL_DECISION_PATH", "/api/swarms/{swarm_id}/approvals/{approval_id}"),
Timeout: time.Duration(timeoutSec) * time.Second,
}
}
@@ -359,12 +362,136 @@ func agnetRuntimeStopPath(cfg agnetRuntimeConfig, runtimeDeploymentID string) st
return strings.ReplaceAll(path, "{deployment_id}", url.PathEscape(runtimeDeploymentID))
}
func agnetRuntimeStopPathForRecord(cfg agnetRuntimeConfig, record agnetDeploymentRecord) string {
path := agnetRuntimeStopPath(cfg, strings.TrimSpace(record.RuntimeDeploymentID))
replacer := strings.NewReplacer(
"{swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{runtime_swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{runtime_deployment_id}", url.PathEscape(strings.TrimSpace(record.RuntimeDeploymentID)),
"{manager_deployment_id}", url.PathEscape(strings.TrimSpace(record.DeploymentID)),
)
return replacer.Replace(path)
}
func agnetRuntimeApprovalDecisionPath(cfg agnetRuntimeConfig, record agnetDeploymentRecord, approvalID string) string {
path := strings.TrimSpace(cfg.ApprovalDecisionPath)
if path == "" {
path = "/api/swarms/{swarm_id}/approvals/{approval_id}"
}
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)),
"{approval_id}", url.PathEscape(strings.TrimSpace(approvalID)),
)
return replacer.Replace(path)
}
func callAgnetRuntimeApprovalDecision(ctx context.Context, cfg agnetRuntimeConfig, record agnetDeploymentRecord, approval model.AgnetApprovalRequest, lease *model.AgnetCredentialLease, decision string) error {
endpoint, err := agnetRuntimeURL(cfg.BaseURL, agnetRuntimeApprovalDecisionPath(cfg, record, approval.ApprovalID))
if err != nil {
return err
}
payload := gin.H{
"approval_id": approval.ApprovalID,
"decision": decision,
"manager_deployment_id": record.DeploymentID,
"runtime_deployment_id": record.RuntimeDeploymentID,
"swarm_id": record.RuntimeSwarmID,
"operation": approval.Operation,
"resource_id": approval.ResourceID,
"resource_type": approval.ResourceType,
"resource_scope": approval.ResourceScope,
"target_role": approval.TargetRole,
"risk_level": approval.RiskLevel,
"requires_credential": approval.RequiresCredential,
"decided_by": approval.DecidedBy,
"reason": approval.DecisionReason,
"decided_at": approval.DecidedAt,
}
if approval.UserId > 0 {
payload["user_id"] = fmt.Sprintf("%d", approval.UserId)
}
if lease != nil && strings.TrimSpace(lease.CredentialRef) != "" {
payload["credential_ref"] = strings.TrimSpace(lease.CredentialRef)
payload["lease_id"] = strings.TrimSpace(lease.LeaseID)
payload["lease_expires_at"] = lease.ExpiresAt
}
body, err := common.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Correlation-ID", firstNonEmpty(record.Plan.Metadata.CorrelationID, approval.ApprovalID))
req.Header.Set("X-Idempotency-Key", "approval-decision-"+approval.ApprovalID+"-"+decision)
if cfg.Token != "" {
req.Header.Set("Authorization", "Bearer "+cfg.Token)
}
client := &http.Client{Timeout: cfg.Timeout}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return readErr
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("runtime approval decision returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
if len(respBody) == 0 {
return nil
}
var envelope map[string]any
if err := common.Unmarshal(respBody, &envelope); err != nil {
return err
}
if message := agnetRuntimeEnvelopeError(envelope); message != "" {
return errors.New(message)
}
return nil
}
func syncAgnetRuntimeApprovalDecision(c *gin.Context, approval *model.AgnetApprovalRequest, lease *model.AgnetCredentialLease, decision string) {
if approval == nil || strings.TrimSpace(approval.DeploymentID) == "" {
return
}
cfg := agnetRuntimeClientConfig()
if !cfg.Enabled {
return
}
record, ok := findAgnetDeploymentRecord(approval.DeploymentID)
if !ok {
recordAgnetApprovalAudit("runtime.approval_decision.skipped", approval, lease, "skipped", "deployment not found")
return
}
if strings.TrimSpace(record.RuntimeSwarmID) == "" && strings.TrimSpace(record.RuntimeDeploymentID) == "" {
recordAgnetApprovalAudit("runtime.approval_decision.skipped", approval, lease, "skipped", "runtime identifiers missing")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), cfg.Timeout)
defer cancel()
if err := callAgnetRuntimeApprovalDecision(ctx, cfg, record, *approval, lease, decision); err != nil {
recordAgnetApprovalAudit("runtime.approval_decision.failed", approval, lease, "failed", truncateAgnetFailureReason(err.Error()))
common.SysLog("Agnet runtime approval decision failed for " + approval.ApprovalID + ": " + err.Error())
return
}
recordAgnetApprovalAudit("runtime.approval_decision.accepted", approval, lease, "ok", "")
}
func callAgnetRuntimeStop(ctx context.Context, cfg agnetRuntimeConfig, record agnetDeploymentRecord, reason string) (agnetRuntimeSyncResult, error) {
runtimeDeploymentID := strings.TrimSpace(record.RuntimeDeploymentID)
if runtimeDeploymentID == "" {
return agnetRuntimeSyncResult{}, nil
}
endpoint, err := agnetRuntimeURL(cfg.BaseURL, agnetRuntimeStopPath(cfg, runtimeDeploymentID))
endpoint, err := agnetRuntimeURL(cfg.BaseURL, agnetRuntimeStopPathForRecord(cfg, record))
if err != nil {
return agnetRuntimeSyncResult{}, err
}
+3
View File
@@ -159,6 +159,9 @@ export type AgnetDeployment = {
status: string
phase: string
runtime_state?: string
runtime_deployment_id?: string
runtime_swarm_id?: string
runtime_last_sync_at?: string
failure_reason?: string
created_at: string
updated_at: string
+105 -3
View File
@@ -9,6 +9,7 @@ import {
CheckCircle2,
CircleDashed,
Coins,
Cpu,
FileSearch,
Filter,
GitCommit,
@@ -267,6 +268,35 @@ function describeScope(dep: AgnetDeployment): string {
)
}
function compactRuntimeRef(value: string | undefined): string {
const trimmed = (value || '').trim()
if (!trimmed) return '—'
if (trimmed.length <= 18) return trimmed
return `${trimmed.slice(0, 10)}…${trimmed.slice(-6)}`
}
function formatRecordSource(value: unknown): string {
const source = String(value || '').trim()
if (!source) return 'manager'
if (source.includes('simulate')) return 'simulated'
if (source.includes('runtime') || source.includes('swarm')) return 'runtime'
return source
}
function artifactTypeToneClass(value: unknown): string {
const artifactType = String(value || '').toLowerCase()
if (artifactType.includes('patch') || artifactType.includes('code')) {
return 'bg-sky-500/15 text-sky-300 ring-sky-500/30'
}
if (artifactType.includes('test') || artifactType.includes('report')) {
return 'bg-emerald-500/15 text-emerald-300 ring-emerald-500/30'
}
if (artifactType.includes('deploy')) {
return 'bg-amber-500/15 text-amber-300 ring-amber-500/30'
}
return 'bg-muted/40 text-muted-foreground ring-border/60'
}
function collectResourceGrants(
dep: AgnetDeployment
): Record<string, unknown>[] {
@@ -362,6 +392,9 @@ function RunDetailPanel({ dep }: { dep: AgnetDeployment }) {
void queryClient.invalidateQueries({
queryKey: ['agnet', 'deployment-events', dep.deployment_id],
})
void queryClient.invalidateQueries({
queryKey: ['agnet', 'deployment-timeline', dep.deployment_id],
})
toast.success(t('Simulated events recorded'))
},
onError: (err) => {
@@ -434,6 +467,24 @@ function RunDetailPanel({ dep }: { dep: AgnetDeployment }) {
/>
</div>
<div className='mt-3 grid gap-2 sm:grid-cols-3'>
<MetaPill
icon={Cpu}
label={t('runtime')}
value={dep.runtime_state || 'control-plane'}
/>
<MetaPill
icon={Rocket}
label='runtime_id'
value={compactRuntimeRef(dep.runtime_deployment_id)}
/>
<MetaPill
icon={GitCommit}
label='swarm_id'
value={compactRuntimeRef(dep.runtime_swarm_id)}
/>
</div>
<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='flex items-center justify-between gap-3'>
@@ -968,12 +1019,27 @@ function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
key={String(item.artifact_id || idx)}
className='bg-muted/25 rounded-lg p-2'
>
<p className='truncate text-xs font-medium'>
{String(item.title || item.artifact_id || t('Artifact'))}
</p>
<div className='flex items-center justify-between gap-2'>
<p className='min-w-0 truncate text-xs font-medium'>
{String(item.title || item.artifact_id || t('Artifact'))}
</p>
<span
className={cn(
'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium ring-1 ring-inset',
artifactTypeToneClass(item.artifact_type)
)}
>
{String(item.artifact_type || 'artifact')}
</span>
</div>
<p className='text-muted-foreground mt-1 line-clamp-2 text-[11px]'>
{String(item.summary || item.uri || '—')}
</p>
{Boolean(item.uri) && (
<p className='text-muted-foreground mt-1 truncate font-mono text-[10px]'>
{String(item.uri)}
</p>
)}
</li>
))}
</ul>
@@ -1007,6 +1073,42 @@ function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
)}
</div>
</div>
<div className='mt-3 bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
<p className='text-foreground text-xs font-semibold'>
{t('Merged timeline')}
</p>
{timeline.length === 0 ? (
<p className='text-muted-foreground mt-2 text-xs'>
{t('No timeline records yet')}
</p>
) : (
<ol className='mt-2 max-h-52 space-y-2 overflow-y-auto pe-1'>
{timeline.slice(0, 8).map((item, idx) => {
const event = String(item.event_type || item.event || 'event')
const source = formatRecordSource(item.source)
return (
<li
key={String(item.event_id || item.artifact_id || idx)}
className='bg-muted/25 rounded-lg p-2'
>
<div className='flex items-center justify-between gap-2'>
<p className='truncate font-mono text-[11px]'>{event}</p>
<span className='text-muted-foreground shrink-0 rounded-full bg-background/60 px-2 py-0.5 text-[10px]'>
{source}
</span>
</div>
{Boolean(item.title || item.summary || item.checkpoint) && (
<p className='text-muted-foreground mt-1 line-clamp-2 text-[11px]'>
{String(item.title || item.summary || item.checkpoint)}
</p>
)}
</li>
)
})}
</ol>
)}
</div>
</div>
)
}