Files
heicode-mananger/heicode/controller/agent_swarm_query.go
T
chenchenandClaude Opus 4.8 59b13ba824 feat(swarm): HM-side Swarm Run read-only query — Phase1 (#45)
#45 Phase1 的只读查询(list/status/events?after/artifacts),全部基于 HM 已持久化的
运行时回调数据(Swarm → HM 带签名回调,见 agent_callback.go),**无需实时调 Swarm**,
因此不被 agent_swarm#2 契约冻结阻塞、返工风险低:

- GET /api/heicode/swarms — 列出当前用户的 swarm 运行(AgentDeployment, sub_mode=swarm 或有 runtime_swarm_id)
- GET /api/heicode/swarms/:id — 状态(:id = deployment_id/swarm_id/correlation_id 任一)
- GET /api/heicode/swarms/:id/events?after=&limit= — 事件增量拉取(id 游标 next_after,oldest-first)
  新增 model.ListSwarmCallbackEventsAfter(按 deployment_id/swarm_id + id>after)
- GET /api/heicode/swarms/:id/artifacts — 从已存事件(event_type 含 artifact)派生
- POST /api/heicode/swarms/:id/stop — 唯一写操作;在 agent_swarm#2 冻结 + SWARM_RUNTIME_ENABLED=true
  前默认关闭并明确提示(不臆造未冻结写接口)

字段口径对齐 agent_swarm/docs/integration/runtime-contract.md(deployment_id↔swarm_id↔
manager_deployment_id;状态机 waiting_approval→running→…)。所有查询按 user 作用域,视图脱敏
(不含 plan/payload 大字段与凭据)。

测试:TestListSwarmCallbackEventsAfter(游标/过滤/空标识);TestMain 迁移 AgentDeployment +
AgentCallbackEvent。go build/vet 干净,controller+model 全套回归通过。文档 §5.2。

Affects: Manager only(新增只读查询端点 + 一个 gated 写端点)。无计费/审计 schema 改动;
不依赖未冻结契约。stop 真实接入随 agent_swarm#2 冻结落地。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 13:24:34 +08:00

192 lines
7.1 KiB
Go

package controller
import (
"errors"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
"gorm.io/gorm"
)
// HM-side Swarm Run query (#45 Phase1: list / status / events?after / artifacts / stop).
//
// 设计要点(关键):**只读查询全部基于 HM 本地已持久化的数据**——Swarm 运行时通过带签名回调
// (`/api/agent/callbacks/runtime-events`,见 agent_callback.go)把生命周期事件推给 HM,HM 落
// 到 AgentDeployment + AgentCallbackEvent。因此 list/status/events/artifacts **无需**调用 Swarm
// 运行时,也就**不依赖 `agent_swarm#2` 尚未冻结的拉取契约**,返工风险低。
//
// 仅 `stop`(写操作)需要真正调用 Swarm 运行时;在契约冻结 + `SWARM_RUNTIME_ENABLED=true` 前
// 默认关闭并明确提示(不 mock,不臆造未冻结的写接口)。
//
// 字段口径对齐 agent_swarm/docs/integration/runtime-contract.md(草案):
// deployment_id ↔ swarm_id ↔ manager_deployment_id 三者映射;事件按 swarm_id/deployment_id 持久化。
// findUserSwarmDeployment 按 :id(匹配 deployment_id / runtime_swarm_id / correlation_id)加载
// 当前用户的 swarm 部署。非本人或非 swarm 模式 → 失败。
func findUserSwarmDeployment(c *gin.Context) (model.AgentDeployment, bool) {
userID := c.GetInt("id")
id := strings.TrimSpace(c.Param("id"))
var dep model.AgentDeployment
err := model.DB.Where(
"user_id = ? AND (deployment_id = ? OR runtime_swarm_id = ? OR correlation_id = ?)",
strconv.Itoa(userID), id, id, id,
).First(&dep).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
agentError(c, "POLICY_REJECTED", "swarm run not found")
} else {
agentError(c, "DEPLOYMENT_CONFLICT", "failed to load swarm run")
}
return model.AgentDeployment{}, false
}
if !isSwarmDeployment(dep) {
agentError(c, "POLICY_REJECTED", "deployment is not a swarm run")
return model.AgentDeployment{}, false
}
return dep, true
}
func isSwarmDeployment(dep model.AgentDeployment) bool {
return strings.EqualFold(dep.SubMode, "swarm") || strings.TrimSpace(dep.RuntimeSwarmID) != ""
}
// swarmDeploymentView 是 swarm 运行的状态摘要视图(脱敏:不含 plan/payload 等大字段与凭据)。
func swarmDeploymentView(dep model.AgentDeployment) gin.H {
return gin.H{
"deployment_id": dep.DeploymentID,
"swarm_id": dep.RuntimeSwarmID,
"runtime_deployment_id": dep.RuntimeDeploymentID,
"correlation_id": dep.CorrelationID,
"status": dep.Status,
"phase": dep.Phase,
"runtime_state": dep.RuntimeState,
"failure_reason": dep.FailureReason,
"created_at": dep.CreatedAtText,
"updated_at": dep.UpdatedAtText,
"runtime_last_sync_at": dep.RuntimeLastSyncAtText,
}
}
// swarmEventView maps a persisted callback event to the client view.
func swarmEventView(e model.AgentCallbackEvent) gin.H {
return gin.H{
"id": e.Id, // 作为下一次 ?after= 的游标
"event_id": e.EventID,
"event_type": e.EventType,
"task_id": e.TaskID,
"agent_instance_id": e.AgentInstanceID,
"result": e.Result,
"occurred_at": e.OccurredAt,
"created_at_ms": e.CreatedAtMs,
"payload": unmarshalResourceJSON(e.PayloadJSON),
}
}
// HeicodeListSwarms: GET /api/heicode/swarms — 列出当前用户的 swarm 运行(读 HM 本地部署表)。
func HeicodeListSwarms(c *gin.Context) {
userID := c.GetInt("id")
if userID <= 0 {
agentError(c, "POLICY_REJECTED", "authentication required")
return
}
if model.DB == nil {
agentError(c, "DEPLOYMENT_PERSIST_FAILED", "database not initialised")
return
}
var rows []model.AgentDeployment
err := model.DB.Where(
"user_id = ? AND (LOWER(sub_mode) = ? OR runtime_swarm_id <> '')",
strconv.Itoa(userID), "swarm",
).Order("created_at_ms desc, id desc").Limit(200).Find(&rows).Error
if err != nil {
agentError(c, "DEPLOYMENT_CONFLICT", "failed to list swarm runs")
return
}
items := make([]gin.H, 0, len(rows))
for _, r := range rows {
items = append(items, swarmDeploymentView(r))
}
common.ApiSuccess(c, gin.H{"items": items, "total": len(items)})
}
// HeicodeGetSwarmStatus: GET /api/heicode/swarms/:id — 单个 swarm 运行状态。
func HeicodeGetSwarmStatus(c *gin.Context) {
dep, ok := findUserSwarmDeployment(c)
if !ok {
return
}
common.ApiSuccess(c, swarmDeploymentView(dep))
}
// HeicodeListSwarmEvents: GET /api/heicode/swarms/:id/events?after=&limit= — 事件增量拉取。
func HeicodeListSwarmEvents(c *gin.Context) {
dep, ok := findUserSwarmDeployment(c)
if !ok {
return
}
after, _ := strconv.Atoi(strings.TrimSpace(c.Query("after")))
limit, _ := strconv.Atoi(strings.TrimSpace(c.Query("limit")))
events, err := model.ListSwarmCallbackEventsAfter(dep.DeploymentID, dep.RuntimeSwarmID, after, limit)
if err != nil {
agentError(c, "DEPLOYMENT_CONFLICT", "failed to list swarm events")
return
}
items := make([]gin.H, 0, len(events))
nextAfter := after
for _, e := range events {
items = append(items, swarmEventView(e))
if e.Id > nextAfter {
nextAfter = e.Id
}
}
common.ApiSuccess(c, gin.H{"items": items, "next_after": nextAfter, "count": len(items)})
}
// HeicodeListSwarmArtifacts: GET /api/heicode/swarms/:id/artifacts — 从已持久化事件中筛产物。
// 当前从 event_type 含 "artifact" 的回调事件派生(真实数据);专用 artifact 端点待 agent_swarm#2 冻结后补。
func HeicodeListSwarmArtifacts(c *gin.Context) {
dep, ok := findUserSwarmDeployment(c)
if !ok {
return
}
events, err := model.ListSwarmCallbackEventsAfter(dep.DeploymentID, dep.RuntimeSwarmID, 0, 1000)
if err != nil {
agentError(c, "DEPLOYMENT_CONFLICT", "failed to list swarm artifacts")
return
}
items := make([]gin.H, 0)
for _, e := range events {
if strings.Contains(strings.ToLower(e.EventType), "artifact") {
items = append(items, swarmEventView(e))
}
}
common.ApiSuccess(c, gin.H{"items": items, "total": len(items),
"note": "derived from persisted runtime events; dedicated artifact contract pending agent_swarm#2 freeze"})
}
// HeicodeStopSwarm: POST /api/heicode/swarms/:id/stop — 停止 swarm 运行(写操作)。
// 这是唯一需要真正调用 Swarm 运行时的操作。在 agent_swarm#2 契约冻结 + SWARM_RUNTIME_ENABLED=true
// 前默认关闭并明确提示(不臆造未冻结的写接口)。冻结后在此接入运行时 stop(草案路径
// /api/agent/swarm/deployments/{id}/stop)。
func HeicodeStopSwarm(c *gin.Context) {
dep, ok := findUserSwarmDeployment(c)
if !ok {
return
}
if !common.GetEnvOrDefaultBool("SWARM_RUNTIME_ENABLED", false) {
agentError(c, "POLICY_REJECTED",
"swarm stop is not enabled yet: pending agent_swarm#2 runtime-contract freeze and SWARM_RUNTIME_ENABLED=true")
return
}
// 契约冻结后在此调用 Swarm 运行时 stop;当前仅在本地记录意图,避免对未冻结写接口下注。
common.ApiSuccess(c, gin.H{
"deployment_id": dep.DeploymentID,
"swarm_id": dep.RuntimeSwarmID,
"accepted": true,
"note": "runtime stop wiring lands with agent_swarm#2 contract freeze",
})
}