diff --git a/docs/integration/heicode-desktop-client-api.md b/docs/integration/heicode-desktop-client-api.md index 369ec06c..972b800e 100644 --- a/docs/integration/heicode-desktop-client-api.md +++ b/docs/integration/heicode-desktop-client-api.md @@ -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 已冻结)落地。 --- diff --git a/heicode/controller/agent_callback.go b/heicode/controller/agent_callback.go index f61b869c..b4765029 100644 --- a/heicode/controller/agent_callback.go +++ b/heicode/controller/agent_callback.go @@ -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), diff --git a/heicode/controller/agent_swarm_query.go b/heicode/controller/agent_swarm_query.go index cd6fee90..71da9210 100644 --- a/heicode/controller/agent_swarm_query.go +++ b/heicode/controller/agent_swarm_query.go @@ -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 运行(写操作)。 diff --git a/heicode/controller/agent_swarm_query_test.go b/heicode/controller/agent_swarm_query_test.go index aed81e98..e6ee5f4e 100644 --- a/heicode/controller/agent_swarm_query_test.go +++ b/heicode/controller/agent_swarm_query_test.go @@ -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, "…")) +} diff --git a/heicode/model/agent_callback.go b/heicode/model/agent_callback.go index 85cb14a2..790bdd88 100644 --- a/heicode/model/agent_callback.go +++ b/heicode/model/agent_callback.go @@ -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"`