Merge pull request #59 from xmindlab-heicode/feat/swarm-contract-adoption-v1

feat(swarm): adopt frozen agent_swarm contract v1 — events/sequence/artifact/status (#45/#46)
This commit is contained in:
Fasthei
2026-06-10 21:29:49 +08:00
committed by GitHub
5 changed files with 188 additions and 10 deletions
+15 -5
View File
@@ -329,18 +329,28 @@ signature = base64( ed25519_sign( device_priv, sha256(canonical) ) )
// GET /api/heicode/swarms/:id
{ "success": true, "data": {
"deployment_id":"dep_…", "swarm_id":"swarm-…", "correlation_id":"…",
"status":"running", "phase":"…", "runtime_state":"…", "failure_reason":"",
"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":"…" }}
// GET /api/heicode/swarms/:id/events?after=120
{ "success": true, "data": {
"items":[ {"id":121,"event_type":"task.completed","task_id":"…","result":"ok","occurred_at":"…","payload":{…}} ],
"items":[ {"id":121,"sequence":42,"event_type":"task.completed","task_id":"…","result":"ok","occurred_at":"…","payload":{…}} ],
"next_after":121, "count":1 }}
// GET /api/heicode/swarms/:id/artifacts (从 artifact.created 事件派生,扁平)
{ "success": true, "data": {
"items":[ {"event_id":"evt-…","sequence":50,"task_id":"…","uri":"azblob://…","checksum":"sha256:…","created_at":"…"} ],
"total":1 }}
```
- 状态机(契约 §4):`waiting_approval → running →(blocked ⇄ running)→ completed/failed/stopped`。
- `events` 用 `id` 游标(`next_after`)增量轮询;事件 `payload` **已脱敏**(递归剔除 `secret_ref`/credentials/大字段 + `RedactText` 兜底,绝不下发 `azkv://` secret_ref 或 sk-/Bearer)。事件查询按**当前用户**作用域(防跨用户泄漏)。
- `stop` 在契约冻结前**一律不伪造成功**:未启用 → `POLICY_REJECTED`;即使 `SWARM_RUNTIME_ENABLED=true` 也返回 `NOT_IMPLEMENTED`(未真正转发运行时),直到 `agent_swarm#2` 冻结后接上真实 stop。
- 状态机(契约 §4):`waiting_approval → running →(blocked ⇄ running)→ completed/failed/stopped`;客户端展示用 `display_status`(§4.1:`blocked→degraded`;`preparing`/`verifying` 是运行时 running 子态,HM 未单独存,不臆造)。
- **事件**:`sequence` = agent_swarm event-schema v1 的 **per-swarm 严格递增序号**(每 swarm 从 1、无空洞),客户端用它去重/排序;`id`/`next_after` 是 HM 不透明分页游标(单调,兼容 `sequence` 尚未全量上线)。事件 `payload` **已脱敏**(递归剔除 `secret_ref`/`credential_ref`/`signing_secret_ref`/credentials/大字段 + `RedactText` 兜底)。查询按**当前用户**作用域。
- **新增事件类型**(已注册):`swarm.completed/failed/stopped`、`approval.approved/rejected`、`handoff.created`(event-schema v1)。
- **artifact**:从 `artifact.created` 派生扁平 `{uri,checksum,task_id,size_bytes?,created_at}`(**无 secret_ref**;size 未知则省略,不伪造)。
- `stop`(写):仍 gated,待 stop 真实接入 PR(复用运行时客户端 + `SWARM_RUNTIME_SERVICE_TOKEN`,契约 §3 已冻结)落地。
---
+16
View File
@@ -29,6 +29,7 @@ type agentCallbackEnvelope struct {
TaskID string `json:"task_id"`
OccurredAt string `json:"occurred_at"`
CorrelationID string `json:"correlation_id"`
Sequence int `json:"sequence"`
Source string `json:"source"`
Metadata map[string]any `json:"metadata"`
Payload map[string]any `json:"payload"`
@@ -478,6 +479,14 @@ var agentCallbackEventRequiredFields = map[string][]string{
"handoff.requested": {"task_id", "from_role", "to_role"},
"handoff.completed": {"task_id", "from_role", "to_role"},
"approval.requested": {"approval_id", "operation", "risk_level"},
// agent_swarm event-schema FROZEN v1 新增 6 类(#15)。必填字段先按最小集登记
// (避免误拒);agent_swarm PR #28 合并后按其 §4 表精校。
"handoff.created": {"task_id"},
"approval.approved": {"approval_id"},
"approval.rejected": {"approval_id"},
"swarm.completed": {},
"swarm.failed": {"reason"},
"swarm.stopped": {},
"artifact.created": {"artifact_id"},
"timeline.updated": {"title"},
"sk_tool.called": {"tool_name", "tool_invocation_id"},
@@ -503,7 +512,13 @@ var agentCallbackEventCategories = map[string]string{
"task.completed": "swarm_task_flow",
"handoff.requested": "swarm_task_flow",
"handoff.completed": "swarm_task_flow",
"handoff.created": "swarm_task_flow",
"approval.requested": "approval",
"approval.approved": "approval",
"approval.rejected": "approval",
"swarm.completed": "swarm_lifecycle",
"swarm.failed": "swarm_lifecycle",
"swarm.stopped": "swarm_lifecycle",
"artifact.created": "artifact",
"timeline.updated": "timeline",
"sk_tool.called": "sk",
@@ -709,6 +724,7 @@ func AgentReceiveRuntimeEventCallback(c *gin.Context) {
UserID: record.Plan.UserContext.UserID,
BindingScope: firstPlanBindingScope(record.Plan),
CorrelationID: strings.TrimSpace(payload.CorrelationID),
Sequence: payload.Sequence,
Source: strings.TrimSpace(payload.Source),
Result: "ok",
PayloadJSON: string(payloadJSON),
+84 -5
View File
@@ -60,7 +60,9 @@ func swarmDeploymentView(dep model.AgentDeployment) gin.H {
"swarm_id": dep.RuntimeSwarmID,
"runtime_deployment_id": dep.RuntimeDeploymentID,
"correlation_id": dep.CorrelationID,
"status": dep.Status,
"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,
@@ -70,10 +72,48 @@ 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 未单独存,
// 不臆造(规则:无信号不造态)。
func swarmDisplayStatus(status string) string {
if strings.EqualFold(strings.TrimSpace(status), "blocked") {
return "degraded"
}
return status
}
// swarmSensitivePayloadKeys 是事件 payload 中**绝不下发**给客户端的键(凭据/大字段)。
// 递归剔除(#45 复审 #1:回调 envelope 可能含 secret_ref,如 approval.requested)。
var swarmSensitivePayloadKeys = map[string]bool{
"secret_ref": true, "secretref": true, "credentials": true, "credential": true,
// agent_swarm event-schema v1(#15):envelope 按设计透传 azkv:// 引用,客户端可见视图须剔除。
"credential_ref": true, "signing_secret_ref": true,
"secret": true, "token": true, "access_token": true, "refresh_token": true,
"api_key": true, "apikey": true, "private_key": true, "access_key": true,
"password": true, "passwd": true,
@@ -124,7 +164,8 @@ func sanitizeSwarmPayload(raw string) map[string]any {
// swarmEventView maps a persisted callback event to the client view (payload 脱敏)。
func swarmEventView(e model.AgentCallbackEvent) gin.H {
return gin.H{
"id": e.Id, // 作为下一次 ?after= 的游标
"id": e.Id, // HM 不透明分页游标(next_after);单调,兼容 sequence 未上线
"sequence": e.Sequence, // agent_swarm v1 per-swarm 序号(客户端去重/排序;0=envelope 未带)
"event_id": e.EventID,
"event_type": e.EventType,
"task_id": e.TaskID,
@@ -211,11 +252,49 @@ func HeicodeListSwarmArtifacts(c *gin.Context) {
items := make([]gin.H, 0)
for _, e := range events {
if strings.Contains(strings.ToLower(e.EventType), "artifact") {
items = append(items, swarmEventView(e))
items = append(items, swarmArtifactView(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"})
common.ApiSuccess(c, gin.H{"items": items, "total": len(items)})
}
// swarmArtifactView 从 artifact.created 事件产出客户端扁平视图(agent_swarm event-schema v1):
// {uri, checksum, task_id, size_bytes?, created_at}。值取自(已脱敏的)payload —— uri/checksum
// 非敏感保留;**绝不含 secret_ref**。size 未知则省略(不伪造,契约要求)。
func swarmArtifactView(e model.AgentCallbackEvent) gin.H {
p := sanitizeSwarmPayload(e.PayloadJSON)
get := func(k string) any {
if p == nil {
return nil
}
return p[k]
}
createdAt := get("created_at")
if createdAt == nil || createdAt == "" {
createdAt = e.OccurredAt
}
out := gin.H{
"event_id": e.EventID,
"sequence": e.Sequence,
"task_id": firstNonNil(get("task_id"), e.TaskID),
"uri": get("uri"),
"checksum": get("checksum"),
"created_at": createdAt,
}
if sz := get("size_bytes"); sz != nil { // 未知则省略,不伪造
out["size_bytes"] = sz
}
return out
}
// firstNonNil 返回第一个非 nil/非空的值。
func firstNonNil(vals ...any) any {
for _, v := range vals {
if v != nil && v != "" {
return v
}
}
return nil
}
// HeicodeStopSwarm: POST /api/heicode/swarms/:id/stop — 停止 swarm 运行(写操作)。
@@ -2,8 +2,10 @@ package controller
import (
"encoding/json"
"strings"
"testing"
"github.com/heicode/manager/model"
"github.com/stretchr/testify/require"
)
@@ -44,3 +46,71 @@ func TestSanitizeSwarmPayload_EmptyAndPlain(t *testing.T) {
out := sanitizeSwarmPayload(`{"route":"chat","n":3}`)
require.Equal(t, "chat", out["route"])
}
// #45/§4.1: 状态展示映射 —— blocked→degraded,其余直通(不臆造 preparing/verifying)。
func TestSwarmDisplayStatus(t *testing.T) {
require.Equal(t, "degraded", swarmDisplayStatus("blocked"))
require.Equal(t, "degraded", swarmDisplayStatus("BLOCKED"))
require.Equal(t, "running", swarmDisplayStatus("running"))
require.Equal(t, "completed", swarmDisplayStatus("completed"))
require.Equal(t, "waiting_approval", swarmDisplayStatus("waiting_approval"))
}
// #15: artifact.created → 扁平视图 {uri,checksum,task_id,created_at}(size 缺省省略);绝不泄 secret_ref。
func TestSwarmArtifactView(t *testing.T) {
e := model.AgentCallbackEvent{
EventID: "evt-a1", Sequence: 5, TaskID: "t9", OccurredAt: "2026-06-10T00:00:00Z",
PayloadJSON: `{"uri":"azblob://bucket/x.zip","checksum":"sha256:abc","task_id":"t9","secret_ref":"azkv://kv/secrets/s"}`,
}
v := swarmArtifactView(e)
require.Equal(t, "azblob://bucket/x.zip", v["uri"])
require.Equal(t, "sha256:abc", v["checksum"])
require.Equal(t, "t9", v["task_id"])
require.Equal(t, "2026-06-10T00:00:00Z", v["created_at"]) // payload 无 created_at → 回退 occurred_at
_, hasSize := v["size_bytes"]
require.False(t, hasSize, "size 未知不应出现")
b, _ := json.Marshal(v)
require.NotContains(t, string(b), "secret_ref")
require.NotContains(t, string(b), "azkv://")
// 带 size_bytes 时保留
e2 := model.AgentCallbackEvent{EventID: "evt-a2", PayloadJSON: `{"uri":"u","checksum":"c","size_bytes":1234,"created_at":"2026-06-10T01:00:00Z"}`}
v2 := swarmArtifactView(e2)
require.EqualValues(t, 1234, v2["size_bytes"])
require.Equal(t, "2026-06-10T01:00:00Z", v2["created_at"])
}
// #15: 6 类新事件已注册(类别 + 必填字段两张表)。
func TestSwarmFrozenEventTypesRegistered(t *testing.T) {
for _, et := range []string{
"swarm.completed", "swarm.failed", "swarm.stopped",
"approval.approved", "approval.rejected", "handoff.created",
} {
_, inCat := agentCallbackEventCategories[et]
_, inReq := agentCallbackEventRequiredFields[et]
require.True(t, inCat, "event_type %s 应在 categories 注册", et)
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, "…"))
}
+3
View File
@@ -18,6 +18,9 @@ type AgentCallbackEvent struct {
UserID string `gorm:"type:varchar(64);index" json:"user_id"`
BindingScope string `gorm:"type:varchar(512);index" json:"binding_scope"`
CorrelationID string `gorm:"type:varchar(64);index" json:"correlation_id"`
// agent_swarm event-schema v1(#15):per-swarm 严格递增序号(每 swarm 从 1、无空洞)。
// 0 = envelope 未带(legacy / 非 swarm);对外作客户端续传/去重游标。
Sequence int `gorm:"index;default:0" json:"sequence"`
Source string `gorm:"type:varchar(64)" json:"source"`
Result string `gorm:"type:varchar(32)" json:"result"`
PayloadJSON string `gorm:"type:text" json:"payload_json"`