feat(swarm): adopt frozen agent_swarm contract v1 (read-side) (#45/#46)
按 agent_swarm#14/#15 冻结契约做 HM 读侧适配(客户端 #28 消费): - 注册 6 类新事件(event-schema v1):swarm.completed/failed/stopped、approval.approved/rejected、 handoff.created(categories + requiredFields 两表;必填先最小集,待 agent_swarm PR #28 §4 精校)。 - AgentCallbackEvent 增 Sequence(per-swarm 严格递增序号),回调入库透传 envelope.sequence; 事件视图暴露 sequence 供客户端去重/排序。游标仍用稳定 id(next_after)避免 sequence 未全量 上线时回归。 - 脱敏键补 credential_ref/signing_secret_ref(envelope 按设计透传 azkv:// 引用,客户端视图剔除)。 - artifact.created → 扁平视图 {uri,checksum,task_id,size_bytes?,created_at}(无 secret_ref; size 未知省略不伪造)。 - 状态展示映射 §4.1:display_status(blocked→degraded;不臆造 preparing/verifying)。 测试:状态映射、artifact 视图(脱敏 + size 省略)、6 类事件注册;controller+model 全回归通过, go build/vet 干净。文档 §5.2 更新。 未含(下一 PR):stop 真实运行时接入(写路径,复用 agentRuntimeClientConfigForMode("swarm"))。 Affects: Manager only(只读查询契约适配)。AgentCallbackEvent 加列(AutoMigrate);无计费改动。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -329,18 +329,27 @@ 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,余直通)
|
||||
"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 已冻结)落地。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -60,7 +60,8 @@ 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 映射)
|
||||
"phase": dep.Phase,
|
||||
"runtime_state": dep.RuntimeState,
|
||||
"failure_reason": dep.FailureReason,
|
||||
@@ -70,10 +71,23 @@ func swarmDeploymentView(dep model.AgentDeployment) gin.H {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +138,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 +226,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 运行(写操作)。
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/heicode/manager/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -44,3 +45,49 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user