feat(swarm): add goal_summary to swarm status view (#45/#28 consumer ask)

@Mem0ried 客户端 consumer 验收(#59)指出 GET /swarms/:id 缺 goal_summary —— Run 列表
只能显示 deployment_id/status,体验差。补:swarmDeploymentView 增加 goal_summary,从持久化
plan_json 顶层 objective 提取,折叠空白为单行 + 截断(200 rune)+ RedactText 兜底;取不到
(无 plan / 无 objective / 坏 JSON)返回空串,不臆造。list 与 detail 同走 swarmDeploymentView,
两处都带上。

文档 docs/integration/heicode-desktop-client-api.md §5.2 状态样例补 goal_summary 字段说明。
测试 TestSwarmGoalSummary 覆盖:空/坏 JSON/无 objective→空;多行多空格折叠;误入 sk- 被脱敏;
超长截断带省略号。go build ./... 与 controller 测试全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:17:32 +08:00
co-authored by Claude Opus 4.8
parent 56a9b02a25
commit 292504735e
3 changed files with 52 additions and 2 deletions
@@ -331,6 +331,7 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
"deployment_id":"dep_…", "swarm_id":"swarm-…", "correlation_id":"…",
"status":"blocked", // 运行时真实状态(契约 §4)
"display_status":"degraded", // 客户端展示态(§4.1 映射:blocked→degraded,余直通)
"goal_summary":"…", // 单行目标(从 plan objective 提取,折叠/截断/脱敏;取不到为空串)
"phase":"…", "runtime_state":"…", "failure_reason":"",
"created_at":"…","updated_at":"…","runtime_last_sync_at":"…" }}
+26
View File
@@ -62,6 +62,7 @@ func swarmDeploymentView(dep model.AgentDeployment) gin.H {
"correlation_id": dep.CorrelationID,
"status": dep.Status, // 运行时真实状态(契约 §4)
"display_status": swarmDisplayStatus(dep.Status), // 客户端展示态(契约 §4.1 映射)
"goal_summary": swarmGoalSummary(dep.PlanJSON), // 单行目标(客户端 Run 列表展示;@Mem0ried #28 消费需求)
"phase": dep.Phase,
"runtime_state": dep.RuntimeState,
"failure_reason": dep.FailureReason,
@@ -71,6 +72,31 @@ func swarmDeploymentView(dep model.AgentDeployment) gin.H {
}
}
// swarmGoalSummary 从持久化的 plan_json 提取顶层 objective,折叠为单行供客户端 Run 列表展示
// (#28 消费需求:列表不止 deployment_id/status)。空白折叠 + 截断 + RedactText 兜底(objective
// 是用户目标文本而非密钥,但仍按统一红线剥离误入的 sk-/token);取不到则返回空串(不臆造)。
func swarmGoalSummary(planJSON string) string {
if strings.TrimSpace(planJSON) == "" {
return ""
}
var plan struct {
Objective string `json:"objective"`
}
if err := common.UnmarshalJsonStr(planJSON, &plan); err != nil {
return ""
}
s := strings.TrimSpace(plan.Objective)
if s == "" {
return ""
}
s = strings.Join(strings.Fields(s), " ") // 折叠所有空白(含换行)为单空格
const maxLen = 200
if len([]rune(s)) > maxLen {
s = strings.TrimSpace(string([]rune(s)[:maxLen])) + "…"
}
return model.RedactText(s)
}
// swarmDisplayStatus 把运行时真实状态映射到客户端展示态(agent_swarm runtime-contract §4.1)。
// 运行时只有 waiting_approval/running/blocked/completed/failed/stopped;`blocked` 展示为
// `degraded`,其余直通。preparing/verifying 是 running 的子态、由运行时阶段决定,HM 未单独存,
@@ -2,6 +2,7 @@ package controller
import (
"encoding/json"
"strings"
"testing"
"github.com/heicode/manager/model"
@@ -91,3 +92,25 @@ func TestSwarmFrozenEventTypesRegistered(t *testing.T) {
require.True(t, inReq, "event_type %s 应在 requiredFields 注册", et)
}
}
// #28(@Mem0ried 消费需求):goal_summary 从 plan_json 顶层 objective 提取 —— 折叠空白为单行、
// 截断、脱敏;无 plan / 无 objective / 坏 JSON 返回空串(不臆造)。
func TestSwarmGoalSummary(t *testing.T) {
require.Empty(t, swarmGoalSummary(""))
require.Empty(t, swarmGoalSummary("not-json"))
require.Empty(t, swarmGoalSummary(`{"sub_mode":"swarm"}`)) // 无 objective
// 多行/多空格折叠成单行(JSON 里的 \n 是合法转义,用原始串避免再转义)
got := swarmGoalSummary(`{"objective":" 迁移 支付服务\n 到 K8s "}`)
require.Equal(t, "迁移 支付服务 到 K8s", got)
// 误入的 sk- 被 RedactText 兜底
red := swarmGoalSummary(`{"objective":"deploy with key sk-abcdEFGH1234567890XYZ now"}`)
require.NotContains(t, red, "sk-abcdEFGH1234567890XYZ")
// 超长截断带省略号
long := `{"objective":"` + strings.Repeat("x", 400) + `"}`
out := swarmGoalSummary(long)
require.LessOrEqual(t, len([]rune(out)), 201) // 200 + …
require.True(t, strings.HasSuffix(out, "…"))
}