feat: complete sub task flow callbacks

This commit is contained in:
gongzhiyong
2026-05-27 23:31:57 +08:00
parent 4ccf7b1062
commit 10fc64e172
9 changed files with 453 additions and 30 deletions
@@ -12,7 +12,7 @@
|---|---|
| 可以独立做 | Manager 自己的 DB 模型、Go API、前端页面、权限校验、回调接收骨架、artifact 数据模型、幂等、模拟事件和文档口径 |
| 不能独立做 | 真实 `swarm_id`、worker claim / heartbeat、真实 handoff / retry、真实 Runtime 日志指标、客户端审批弹窗、短期凭证注入 runtime、SK 工具真实调用结果 |
| 当前最大问题 | Manager 已有本地 Agnet control-plane 占位,但普通用户态部署、任务桥接、回调/artifact/幂等和持久化还没形成完整 Manager 闭环 |
| 当前最大问题 | Manager 本地闭环已基本形成;剩余主要是等待真实 Runtime/客户端产生数据后联调验证 |
## 二、排除项
@@ -33,20 +33,20 @@
| 顺序 | 任务 | 优先级 | 是否 Manager 独立 | 完成后价值 |
|---:|---|---|---|---|
| 1 | 增加 sub 模式字段 | P0 | 是 | 明确任务组织方式,支撑瀑布/敏捷状态展示 |
| 2 | 打通 HeicodeTask 到 Agnet deployment 的 Manager 桥接 | P0 | 是 | 不再只能手动创建 deployment |
| 3 | 增加用户态 Agnet deployment API | P0 | 是 | 普通用户可以在自己资源范围内创建/查看/停止 deployment |
| 4 | 统一 `/api/swarms` 与 `/api/agnet/deployments` 边界 | P0 | 是 | 为后续蜂群联调留稳定 adapter |
| 5 | 建 callback 接收端骨架 | P0 | 是 | 先接住事件、artifact、审批请求、usage、status |
| 6 | 建 artifact 数据模型和 API | P0 | 是 | 先把交付物/产物摘要落库并可展示 |
| 7 | 增加回调幂等和签名/服务身份校验骨架 | P1 | 是 | 重复回调不重复写入,生产联调不乱账 |
| 8 | 持久化 SK snapshot | P1 | 是 | 容器重启后任务上下文和审计不丢 |
| 9 | 任务视角审计聚合 | P1 | 是 | 按 task/deployment/correlation_id 看完整 Manager 记录 |
| 10 | 本地模拟蜂群事件冒烟入口 | P1 | 是 | 不等 Runtime,也能自测 Manager 端完整显示链路 |
| 11 | 前端页面补齐独立闭环展示 | P1 | 是 | 用户能看到任务、deployment、事件、artifact、审批和审计关系 |
| 12 | 文档口径清理 | P2 | 是 | 避免旧 Vault/OpenBao/Secret Provider 表述误导 |
| 13 | AWS/GCP 占位提示 | P2 | 是 | 避免用户误以为 AWS/GCP 已可用 |
| 14 | 请求 body 加密策略确认 | P2 | 是 | 明确 HTTPS 传输加密、应用层 body 加密是否需要、敏感字段禁止入参和日志 |
| 1 | 增加 sub 模式字段 | P0 | 是 | 已完成:明确任务组织方式,支撑瀑布/敏捷状态展示 |
| 2 | 打通 HeicodeTask 到 Agnet deployment 的 Manager 桥接 | P0 | 是 | 已完成:任务可生成 deployment draft |
| 3 | 增加用户态 Agnet deployment API | P0 | 是 | 已完成:普通用户可以在自己资源范围内创建/查看/停止 deployment |
| 4 | 统一 `/api/swarms` 与 `/api/agnet/deployments` 边界 | P0 | 是 | 已完成:为后续蜂群联调留稳定 adapter |
| 5 | 建 callback 接收端骨架 | P0 | 是 | 已完成:先接住事件、artifact、审批请求、timeline |
| 6 | 建 artifact 数据模型和 API | P0 | 是 | 已完成:交付物/产物摘要落库并可展示 |
| 7 | 增加回调幂等和签名/服务身份校验骨架 | P1 | 是 | 已完成:重复回调不重复写入,支持 token/HMAC |
| 8 | 持久化 SK snapshot | P1 | 是 | 已完成:容器重启后任务上下文和审计不丢 |
| 9 | 任务视角审计聚合 | P1 | 是 | 已完成:按 deployment 聚合 audit/callback/artifact/SK |
| 10 | 本地模拟蜂群事件冒烟入口 | P1 | 是 | 已完成:默认模拟 task/blocked/retry/handoff/artifact/approval/timeline |
| 11 | 前端页面补齐独立闭环展示 | P1 | 是 | 已完成:展示任务流、deployment、事件、artifact、审批和审计关系 |
| 12 | 文档口径清理 | P2 | 是 | 已完成:当前口径以 Azure Key Vault / secret_ref 为准 |
| 13 | AWS/GCP 占位提示 | P2 | 是 | 已完成:避免用户误以为 AWS/GCP 已可用 |
| 14 | 请求 body 加密策略确认 | P2 | 是 | 已完成:桌面端 sub POST 复用模型调用 V2 body 加密 |
## 四、任务明细
@@ -121,16 +121,17 @@
| 项 | 内容 |
|---|---|
| 目标 | Manager 先具备接收蜂群平台回调的 API 和落库能力 |
| 修改文件 | 新增 `heicode/model/agnet_callback.go`、`heicode/controller/agnet_callback.go`,更新 `heicode/router/api-router.go` |
| 新增接口 | `POST /api/agnet/callbacks/swarm-events`、`/approval-requests`、`/artifacts`、`/usage`、`/status` |
| 独立能力 | 本地模拟 payload 可保存、去重、查询 |
| 测试 | controller 单测覆盖正常保存、重复 event_id 幂等、明文密钥拒绝 |
| 修改文件 | `heicode/model/agnet_callback.go`、`heicode/controller/agnet_callback.go`、`heicode/router/api-router.go` |
| 新增接口 | `POST /api/agnet/callbacks/swarm-events` |
| 独立能力 | 本地模拟 payload 可保存、去重、查询;已补普通 sub 任务事件 schema 校验 |
| 测试 | controller 单测覆盖正常保存、重复 event_id 幂等、明文密钥拒绝、handoff 缺字段拒绝 |
验收标准:
- 请求体不得出现 token/password/private_key/access_key/connection_string 明文字段。
- 每个回调都有 `event_id` 或 `idempotency_key`。
- 重复回调返回成功但不重复写入。
- `task.*` / `handoff.*` 关键事件缺少必填字段时返回 `CALLBACK_SCHEMA_INVALID`。
### 任务 6:artifact 数据模型和 API
@@ -201,14 +202,15 @@
| 目标 | 在蜂群 Runtime 未接入前,Manager 能用模拟事件自测完整链路 |
| 修改文件 | `heicode/controller/agnet_callback.go`、`heicode/router/api-router.go`、可选新增 `heicode/controller/agnet_smoke.go` |
| 接口建议 | admin-only `POST /api/agnet/dev/simulate-run` |
| 生成内容 | deployment accepted、task.created、task.claimed、task.completed、artifact.created、approval.requested |
| 测试 | 单测验证模拟后 timeline/artifacts/audit 可查 |
| 生成内容 | deployment accepted、task.created、task.claimed、task.heartbeat、task.blocked、handoff.requested、handoff.completed、task.retried、task.completed、artifact.created、approval.requested、timeline.updated |
| 测试 | 单测验证模拟后 timeline/artifacts/audit 可查;`scripts/agnet_sub_mode_smoke.py` 可跑生产健康和 deployment timeline 冒烟 |
验收标准:
- 该接口必须 admin-only 或 dev-only。
- 响应明确 `simulated: true`。
- 线上页面不能把模拟事件显示成真实 Runtime 事件。
- task/blocked/retry/handoff 事件能在 timeline 中看到,来源标记为 `simulated`。
### 任务 11:前端页面补齐独立闭环展示
+6 -6
View File
@@ -5,7 +5,7 @@
## 一、对照结论
Manager 端已经具备蜂群联调需要的控制面基础:用户态 deployment、task draft、`/api/swarms` adapter、Runtime 创建/停止桥接、callback 接收、artifact、approval、approval decision 回传 adapter、timeline、SK snapshot、V2 加密请求。本地 `simulate-events` 默认链路也能写入 callback、artifact、approval、timeline 记录,便于 Manager 自测页面展示和脱敏。
Manager 端已经具备蜂群联调需要的控制面基础:用户态 deployment、task draft、`/api/swarms` adapter、Runtime 创建/停止桥接、callback 接收、artifact、approval、approval decision 回传 adapter、timeline、SK snapshot、V2 加密请求。本地 `simulate-events` 默认链路也能写入 task、blocked、retry、handoff、artifact、approval、timeline 记录,便于 Manager 自测页面展示和脱敏。
仍然缺的是“真实蜂群 Runtime 产生的数据和状态机”。也就是说,Manager 不是完全没写;缺口主要集中在 Runtime 真正创建 Swarm Run、任务图、Agent 执行、handoff、产物和审批继续/停止闭环。
@@ -21,8 +21,8 @@ Manager 端已经具备蜂群联调需要的控制面基础:用户态 deployme
| 重复回调幂等 | 已按 `event_id` / `idempotency_key` 去重 | 满足 | 需要蜂群侧稳定传唯一事件 ID | 蜂群 |
| 接收 artifact 回调 | `artifact.created` 可落库并查询 | 满足接收能力 | 真实 artifact schema 和文件/分支引用需 Runtime 输出 | 蜂群 |
| 展示 Swarm 状态、事件、产物 | deployment detail/events/artifacts/timeline 已有,页面已强化 runtime/simulated 来源和 artifact 类型/URI | 基本满足 | 真实展示内容仍依赖 Runtime 回调真实数据 | 蜂群 |
| 展示 task graph、claim、heartbeat | 当前没有真实 task graph 表和状态展示 | 不满足 | 需要 Runtime 产出 task graph/claim/heartbeat 事件 | 蜂群 + Manager |
| 展示 handoff、blocked、retry | callback 可接任意 event,但没有专门展示和字段约束 | 部分满足 | 需要标准事件 schema 和 Runtime 实际事件 | 蜂群 + Manager |
| 展示 task graph、claim、heartbeat | Manager 已可接收并展示普通 task flow callback;没有独立 task graph 状态表 | 部分满足 | 真实任务图仍需要 Runtime 产出 task/agent 事件 | 蜂群 |
| 展示 handoff、blocked、retry | 已补 `task.*` / `handoff.*` schema 校验和页面任务流展示 | 基本满足接收和展示 | 真实数据仍需要 Runtime 持续回调 | 蜂群 |
| 高危审批请求进入 Manager | `approval.requested` callback 可转审批记录;用户 approve/reject 后可按配置 POST 回 Runtime | 部分满足 | 还需要蜂群 Runtime 提供并验证 approval decision 接收接口 | Manager + 蜂群 |
| 高危审批在客户端主体验完成 | Manager 有审批 API | 部分满足 | 桌面客户端要弹窗、轮询/订阅、提交决定 | 客户端 |
| 短期凭证和长期密钥隔离 | Manager 使用 `secret_ref` / `lease://` 记录,不传明文 | 基本满足 | Runtime 侧短期凭证派生/注入未验证 | 蜂群 + 基础设施 |
@@ -67,7 +67,7 @@ desktop-client -> Orchestrator(FastAPI) -> Redis -> Agent Pods
|---|---|---|
| 真实 `POST /api/swarms` 联调 | Manager 有 adapter,蜂群当前可见接口是 `/tasks` | 蜂群侧确认是否补 `/api/swarms`;Manager 固定 adapter 文档 |
| `deployment_id <-> swarm_id` 真实映射 | Manager 字段已准备,真实值需 Runtime 返回 | Runtime create response 必须返回 `swarm_id` |
| task graph / claim / heartbeat 事件 | Manager 可接 callback,但没有真实数据 | 蜂群侧定义并回调 `task.created/claimed/heartbeat/released/completed/failed` |
| task graph / claim / heartbeat 事件 | Manager 可接收/展示 task flow callback;缺真实 Runtime 数据 | 蜂群侧定义并回调 `task.created/claimed/heartbeat/released/completed/failed` |
| artifact 真实产出 | Manager 能落库,缺 Runtime 产出 | 蜂群侧回调 `artifact.created`,带 Git branch/commit 或存储 URI |
| 审批结果回传 Runtime | Manager adapter 已完成,真实闭环未验证 | 蜂群侧提供接收接口并验证 approved/rejected 后继续或停止 |
@@ -75,7 +75,7 @@ desktop-client -> Orchestrator(FastAPI) -> Redis -> Agent Pods
| 缺失项 | 当前状态 | 处理建议 |
|---|---|---|
| handoff/retry/blocked 展示 | 可接通用 callback,但页面未专门展示 | 先定事件 schema,再补 timeline 展示 |
| handoff/retry/blocked 展示 | Manager 已有 schema 校验、模拟事件和页面任务流展示 | 用真实 Runtime callback 做生产联调验证 |
| Runtime 日志/指标 | Manager 有 logs/metrics 位置,缺真实数据源 | 蜂群侧提供日志摘要或查询接口;指标对齐 Prometheus |
| 事件来源标识 | API 有部分 runtime/simulated 状态,页面还需强化 | Manager 页面区分 `manager`、`runtime`、`simulated` |
| 桌面客户端审批主流程 | Manager 有 API,客户端未完成主体验 | 客户端按 Manager approval API 接入 |
@@ -85,7 +85,7 @@ desktop-client -> Orchestrator(FastAPI) -> Redis -> Agent Pods
| 缺失项 | 当前状态 | 处理建议 |
|---|---|---|
| 蜂群验收脚本 | Manager 默认模拟和单测已覆盖 callback/artifact/approval/timeline;生产 curl 脚本仍需整理 | 增加一套可给联调方直接执行的 curl 脚本 |
| 蜂群验收脚本 | 已新增 `scripts/agnet_sub_mode_smoke.py`,可检查生产 Manager、Agent health 和指定 deployment timeline | 后续按真实 Runtime deployment 固化执行参数 |
| 页面术语统一 | sub/蜂群容易混淆 | 页面和文档统一:sub 是任务组织,swarm 是执行层 |
| 兼容 `HeiCode-Swarm` demo client 的说明 | 容易误认为正式 Heicode 桌面客户端 | 文档明确 demo client 不等于 `cc-haha` 正式客户端 |
@@ -1221,7 +1221,7 @@ setInterval(async () => {
## 15. 当前生产注意事项
1. `https://code.xinghanlab.com` 的 Manager 用户态接口已上线,当前生产版本为 `1.4.8`。
1. `https://code.xinghanlab.com` 的 Manager 用户态接口已上线,当前生产版本为 `1.4.9`。
2. Manager 本地控制面可创建 `sub_mode=agile/waterfall` deployment。
3. 生产 Manager 已配置 Agent Manager Runtime,当前直接走 `http://20.212.121.126`;域名和 HTTPS 后续单独处理,不作为客户端当前接入阻塞项。
4. V2 加密 `deployment-draft` 已在生产验证通过:真实构造 `Content-Encoding: heicode-aead-v1` 请求返回 200,`sub_mode=agile`,`user_id=22`。
+1 -1
View File
@@ -1 +1 @@
1.4.8
1.4.9
+63 -1
View File
@@ -328,6 +328,63 @@ func callbackIntValue(values map[string]any, key string) int {
}
}
var agnetCallbackEventRequiredFields = map[string][]string{
"task.created": {"task_id", "title"},
"task.claimed": {"task_id", "agent_role"},
"task.running": {"task_id", "agent_role"},
"task.heartbeat": {"task_id", "agent_role"},
"task.blocked": {"task_id", "reason"},
"task.retried": {"task_id", "attempt"},
"task.released": {"task_id", "agent_role"},
"task.failed": {"task_id", "reason"},
"task.completed": {"task_id"},
"handoff.requested": {"task_id", "from_role", "to_role"},
"handoff.completed": {"task_id", "from_role", "to_role"},
"artifact.created": {"artifact_id"},
}
func callbackEnvelopeFieldValue(payload agnetCallbackEnvelope, key string) string {
switch key {
case "task_id":
return strings.TrimSpace(payload.TaskID)
case "artifact_id":
return strings.TrimSpace(payload.Artifact.ArtifactID)
default:
return ""
}
}
func callbackHasFieldValue(payload agnetCallbackEnvelope, key string) bool {
if callbackEnvelopeFieldValue(payload, key) != "" {
return true
}
if callbackStringValue(payload.Payload, key) != "" {
return true
}
if key == "artifact_id" {
if nested := callbackMapValue(payload.Payload, "artifact"); nested != nil {
return callbackStringValue(nested, key) != ""
}
}
if key == "attempt" {
return callbackIntValue(payload.Payload, key) > 0
}
return false
}
func validateAgnetCallbackEventSchema(payload agnetCallbackEnvelope) error {
required, ok := agnetCallbackEventRequiredFields[payload.EventType]
if !ok {
return nil
}
for _, key := range required {
if !callbackHasFieldValue(payload, key) {
return fmt.Errorf("%s is required for %s callback", key, payload.EventType)
}
}
return nil
}
func persistAgnetApprovalFromCallback(payload agnetCallbackEnvelope, record agnetDeploymentRecord) error {
if payload.EventType != "approval.requested" || model.DB == nil {
return nil
@@ -412,6 +469,10 @@ func AgnetReceiveSwarmEventCallback(c *gin.Context) {
if payload.IdempotencyKey == "" {
payload.IdempotencyKey = payload.EventID
}
if err := validateAgnetCallbackEventSchema(payload); err != nil {
agnetError(c, "CALLBACK_SCHEMA_INVALID", err.Error())
return
}
if agnetCallbackHasPlaintextSecret(payload) {
agnetError(c, "CALLBACK_SECRET_REJECTED", "callbacks must not contain plaintext credential fields")
return
@@ -520,9 +581,10 @@ func timelineEntryFromCallback(callback model.AgnetCallbackEvent) gin.H {
"swarm_id": callback.SwarmID,
"agent_instance_id": callback.AgentInstanceID,
"occurred_at": callback.OccurredAt,
"source": callback.Source,
"payload": payload,
}
for _, key := range []string{"title", "summary", "stage", "checkpoint", "severity", "next_action", "agent_role"} {
for _, key := range []string{"title", "summary", "stage", "checkpoint", "severity", "next_action", "agent_role", "task_id", "from_role", "to_role", "reason", "attempt"} {
if value, ok := payload[key]; ok {
entry[key] = value
}
+48
View File
@@ -1437,17 +1437,65 @@ func persistAgnetDefaultSimulationCallbacks(record agnetDeploymentRecord) error
}
}
callbacks := []agnetCallbackEnvelope{
base("task.created", "task-created", map[string]any{
"title": "Simulated task created",
"summary": "Manager simulator created a child Agnet task for ordinary sub-mode smoke testing",
"agent_role": role,
"severity": "info",
"task_id": taskID,
}),
base("task.claimed", "task-claimed", map[string]any{
"title": "Simulated task claimed",
"summary": "Manager simulator marked a child Agnet task as claimed",
"agent_role": role,
"severity": "info",
"task_id": taskID,
}),
base("task.heartbeat", "task-heartbeat", map[string]any{
"title": "Simulated task heartbeat",
"summary": "Manager simulator received a heartbeat from the child Agnet task",
"agent_role": role,
"severity": "info",
"task_id": taskID,
}),
base("task.blocked", "task-blocked", map[string]any{
"title": "Simulated task blocked",
"summary": "Manager simulator marked a child Agnet task as blocked before handoff",
"agent_role": role,
"severity": "warning",
"task_id": taskID,
"reason": "Waiting for downstream sub-task handoff validation",
}),
base("handoff.requested", "handoff-requested", map[string]any{
"title": "Simulated handoff requested",
"summary": "Manager simulator requested handoff between child Agnet roles",
"severity": "info",
"task_id": taskID,
"from_role": role,
"to_role": firstNonEmpty(grant.TargetRole, role),
}),
base("handoff.completed", "handoff-completed", map[string]any{
"title": "Simulated handoff completed",
"summary": "Manager simulator completed handoff between child Agnet roles",
"severity": "success",
"task_id": taskID,
"from_role": role,
"to_role": firstNonEmpty(grant.TargetRole, role),
}),
base("task.retried", "task-retried", map[string]any{
"title": "Simulated task retry",
"summary": "Manager simulator recorded a retry event for ordinary sub-mode timeline testing",
"agent_role": role,
"severity": "warning",
"task_id": taskID,
"attempt": 2,
}),
base("task.completed", "task-completed", map[string]any{
"title": "Simulated task completed",
"summary": "Manager simulator marked the child Agnet task as completed",
"agent_role": role,
"severity": "success",
"task_id": taskID,
}),
}
artifact := base("artifact.created", "artifact", map[string]any{
@@ -835,6 +835,11 @@ func TestAgnetUserDeploymentDefaultSimulationCreatesRelatedRecords(t *testing.T)
require.Contains(t, timelineRecorder.Body.String(), `"event_type":"artifact.created"`)
require.Contains(t, timelineRecorder.Body.String(), `"event_type":"approval.requested"`)
require.Contains(t, timelineRecorder.Body.String(), `"source":"agnet-simulator"`)
require.Contains(t, timelineRecorder.Body.String(), `"event_type":"task.created"`)
require.Contains(t, timelineRecorder.Body.String(), `"event_type":"task.blocked"`)
require.Contains(t, timelineRecorder.Body.String(), `"event_type":"handoff.requested"`)
require.Contains(t, timelineRecorder.Body.String(), `"event_type":"handoff.completed"`)
require.Contains(t, timelineRecorder.Body.String(), `"event_type":"task.retried"`)
}
func TestAgnetUserSwarmsAdapterCreatesScopedDeployment(t *testing.T) {
@@ -864,6 +869,47 @@ func TestAgnetUserSwarmsAdapterCreatesScopedDeployment(t *testing.T) {
require.Contains(t, recorder.Body.String(), `"user_id":"7"`)
}
func TestAgnetCallbackRejectsInvalidTaskEventSchema(t *testing.T) {
setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
t.Setenv("AGNET_CALLBACK_TOKEN", "callback-token")
plan := baseAgnetResourceGrantPlan()
plan.UserContext.UserID = "7"
for idx := range plan.Agents[0].ResourceGrants {
plan.Agents[0].ResourceGrants[idx].UserID = "7"
}
createRecorder, createEnvelope := postAgnetCreateUserDeployment(t, 7, plan)
require.Equal(t, http.StatusOK, createRecorder.Code)
require.True(t, createEnvelope.Success)
var createBody map[string]any
require.NoError(t, common.Unmarshal(createRecorder.Body.Bytes(), &createBody))
deploymentID := createBody["data"].(map[string]any)["deployment_id"].(string)
body := fmt.Sprintf(`{
"event_id":"evt-invalid-handoff",
"event_type":"handoff.requested",
"deployment_id":%q,
"task_id":"task-1",
"payload":{
"from_role":"backend"
}
}`, deploymentID)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/agnet/callbacks/swarm-events", strings.NewReader(body))
ctx.Request.Header.Set("Content-Type", "application/json")
ctx.Request.Header.Set("X-Agnet-Service-Token", "callback-token")
AgnetReceiveSwarmEventCallback(ctx)
var envelope agnetCreateTestEnvelope
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &envelope))
require.False(t, envelope.Success)
require.Equal(t, "CALLBACK_SCHEMA_INVALID", envelope.Error.Code)
require.Contains(t, envelope.Error.Message, "to_role")
}
func TestAgnetCallbackStoresEventArtifactAndIsIdempotent(t *testing.T) {
db := setupAgnetControlPlaneTestDB(t)
resetAgnetControlPlaneState(t)
@@ -297,6 +297,42 @@ function artifactTypeToneClass(value: unknown): string {
return 'bg-muted/40 text-muted-foreground ring-border/60'
}
function isTaskFlowEvent(eventType: unknown): boolean {
const event = String(eventType || '').toLowerCase()
return event.startsWith('task.') || event.startsWith('handoff.')
}
function recordPayloadValue(
item: Record<string, unknown>,
key: string
): unknown {
const payload = item.payload
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
const fromPayload = (payload as Record<string, unknown>)[key]
if (fromPayload !== undefined && fromPayload !== null) {
return fromPayload
}
}
return item[key]
}
function taskFlowDetail(item: Record<string, unknown>): string {
const taskId = String(recordPayloadValue(item, 'task_id') || '')
const fromRole = String(recordPayloadValue(item, 'from_role') || '')
const toRole = String(recordPayloadValue(item, 'to_role') || '')
const reason = String(recordPayloadValue(item, 'reason') || '')
const attempt = String(recordPayloadValue(item, 'attempt') || '')
const agentRole = String(recordPayloadValue(item, 'agent_role') || '')
const parts = [
taskId && `task ${taskId}`,
agentRole && `role ${agentRole}`,
fromRole && toRole && `${fromRole} -> ${toRole}`,
reason && `reason ${reason}`,
attempt && `attempt ${attempt}`,
].filter(Boolean)
return parts.join(' / ') || '—'
}
function collectResourceGrants(
dep: AgnetDeployment
): Record<string, unknown>[] {
@@ -963,6 +999,13 @@ function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
const artifacts = data?.artifacts ?? []
const snapshots = data?.sk_snapshots ?? []
const timeline = data?.timeline ?? []
const taskFlowRecords = useMemo(
() =>
timeline.filter((item) =>
isTaskFlowEvent(item.event_type || item.event)
),
[timeline]
)
return (
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_52%,transparent)] p-4'>
@@ -1003,6 +1046,48 @@ function RunRelatedRecordsPanel({ deploymentId }: { deploymentId: string }) {
/>
</div>
<div className='mt-4 bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
<div className='flex items-center justify-between gap-2'>
<p className='text-foreground text-xs font-semibold'>
{t('Sub task flow')}
</p>
<span className='text-muted-foreground rounded-full bg-background/60 px-2 py-0.5 text-[10px]'>
{taskFlowRecords.length}
</span>
</div>
{taskFlowRecords.length === 0 ? (
<p className='text-muted-foreground mt-2 text-xs'>
{t('No task, retry or handoff callback records yet')}
</p>
) : (
<ol className='mt-2 max-h-48 space-y-2 overflow-y-auto pe-1'>
{taskFlowRecords.slice(0, 8).map((item, idx) => {
const event = String(item.event_type || item.event || 'event')
const source = formatRecordSource(item.source)
const detail = taskFlowDetail(item)
return (
<li
key={String(item.event_id || `${event}-${idx}`)}
className='bg-muted/25 rounded-lg p-2'
>
<div className='flex items-center justify-between gap-2'>
<p className='min-w-0 truncate font-mono text-[11px]'>
{event}
</p>
<span className='text-muted-foreground shrink-0 rounded-full bg-background/60 px-2 py-0.5 text-[10px]'>
{source}
</span>
</div>
<p className='text-muted-foreground mt-1 truncate text-[11px]'>
{detail}
</p>
</li>
)
})}
</ol>
)}
</div>
<div className='mt-4 grid gap-3 md:grid-cols-2'>
<div className='bg-background/45 rounded-xl border border-dashed border-[color-mix(in_oklch,var(--primary)_22%,var(--border))] p-3'>
<p className='text-foreground text-xs font-semibold'>
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
Smoke test for Heicode Manager ordinary sub-mode / Agnet integration.
Default mode checks public health/status endpoints only. To validate an
authenticated deployment flow, provide:
HEICODE_ACCESS_TOKEN=<manager access token>
HEICODE_USER_ID=<user id>
HEICODE_DEPLOYMENT_ID=<deployment id>
Optional:
HEICODE_RUN_SIMULATE=true
HEICODE_MANAGER_BASE_URL=https://code.xinghanlab.com
AGENT_MANAGER_HEALTH_URL=http://20.212.121.126/api/agnet/health
"""
from __future__ import annotations
import json
import os
import sys
import urllib.error
import urllib.request
BASE = os.getenv("HEICODE_MANAGER_BASE_URL", "https://code.xinghanlab.com").rstrip("/")
AGENT_HEALTH = os.getenv(
"AGENT_MANAGER_HEALTH_URL", "http://20.212.121.126/api/agnet/health"
)
ACCESS_TOKEN = os.getenv("HEICODE_ACCESS_TOKEN", "").strip()
USER_ID = os.getenv("HEICODE_USER_ID", "").strip()
DEPLOYMENT_ID = os.getenv("HEICODE_DEPLOYMENT_ID", "").strip()
RUN_SIMULATE = os.getenv("HEICODE_RUN_SIMULATE", "").strip().lower() in {
"1",
"true",
"yes",
}
UA = "heicode-agnet-sub-mode-smoke/1.0"
FAILS: list[str] = []
def request(
method: str,
url: str,
headers: dict[str, str] | None = None,
body: bytes | None = None,
) -> tuple[int, dict[str, str], str]:
req = urllib.request.Request(url, method=method, data=body)
req.add_header("User-Agent", UA)
for key, value in (headers or {}).items():
req.add_header(key, value)
try:
resp = urllib.request.urlopen(req, timeout=20)
return resp.status, dict(resp.headers), resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
return exc.code, dict(exc.headers), exc.read().decode("utf-8", "replace")
def manager_request(
method: str, path: str, *, auth: bool = False, payload: dict | None = None
) -> tuple[int, dict[str, str], str]:
headers = {"Content-Type": "application/json"}
if auth:
headers["Authorization"] = f"Bearer {ACCESS_TOKEN}"
headers["X-User-ID"] = USER_ID
body = json.dumps(payload).encode("utf-8") if payload is not None else None
return request(method, BASE + path, headers=headers, body=body)
def check(label: str, ok: bool, detail: str = "") -> None:
print(f"[{'OK' if ok else 'FAIL'}] {label}" + (f" -- {detail}" if detail else ""))
if not ok:
FAILS.append(label)
def parse_json(label: str, body: str) -> dict:
try:
parsed = json.loads(body)
check(label, isinstance(parsed, dict))
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError as exc:
check(label, False, str(exc))
return {}
print(f"Manager base: {BASE}")
print(f"Agent health: {AGENT_HEALTH}\n")
code, _headers, body = manager_request("GET", "/api/status")
status = parse_json("Manager /api/status JSON parseable", body)
check("Manager /api/status success", code == 200 and status.get("success") is True, f"HTTP {code}")
version = status.get("data", {}).get("version") if isinstance(status.get("data"), dict) else ""
check("Manager status carries version", bool(version), f"version={version!r}")
code, _headers, body = request("GET", AGENT_HEALTH)
agent = parse_json("Agent Manager health JSON parseable", body)
agent_data = agent.get("data", {}) if isinstance(agent.get("data"), dict) else {}
agent_status = agent_data.get("status") or agent.get("status")
check(
"Agent Manager health reachable",
code == 200 and str(agent_status).lower() in {"healthy", "ok", "up"},
f"HTTP {code}, status={agent_status!r}",
)
if not (ACCESS_TOKEN and USER_ID and DEPLOYMENT_ID):
print("\nAuthenticated deployment checks skipped: set HEICODE_ACCESS_TOKEN, HEICODE_USER_ID and HEICODE_DEPLOYMENT_ID.")
else:
if RUN_SIMULATE:
code, _headers, body = manager_request(
"POST",
f"/api/agnet/user/deployments/{DEPLOYMENT_ID}/simulate-events",
auth=True,
payload={},
)
simulated = parse_json("simulate-events JSON parseable", body)
check(
"simulate-events accepted",
code == 200 and simulated.get("success") is True,
f"HTTP {code}",
)
code, _headers, body = manager_request(
"GET", f"/api/agnet/user/deployments/{DEPLOYMENT_ID}", auth=True
)
detail = parse_json("deployment detail JSON parseable", body)
detail_data = detail.get("data", {}) if isinstance(detail.get("data"), dict) else {}
check(
"deployment detail success",
code == 200 and detail.get("success") is True and detail_data.get("deployment_id") == DEPLOYMENT_ID,
f"HTTP {code}",
)
code, _headers, body = manager_request(
"GET", f"/api/agnet/user/deployments/{DEPLOYMENT_ID}/artifacts", auth=True
)
artifacts = parse_json("artifacts JSON parseable", body)
artifacts_data = artifacts.get("data", {}) if isinstance(artifacts.get("data"), dict) else {}
artifact_items = artifacts_data.get("artifacts") or artifacts_data.get("items") or []
check(
"artifacts endpoint success",
code == 200 and artifacts.get("success") is True and isinstance(artifact_items, list),
f"HTTP {code}, count={len(artifact_items) if isinstance(artifact_items, list) else 'n/a'}",
)
code, _headers, body = manager_request(
"GET", f"/api/agnet/user/deployments/{DEPLOYMENT_ID}/timeline", auth=True
)
timeline = parse_json("timeline JSON parseable", body)
timeline_data = timeline.get("data", {}) if isinstance(timeline.get("data"), dict) else {}
callbacks = timeline_data.get("callbacks") or []
merged = timeline_data.get("timeline") or []
event_types = {
item.get("event_type") or item.get("event")
for item in merged
if isinstance(item, dict)
}
check(
"timeline endpoint success",
code == 200 and timeline.get("success") is True and isinstance(merged, list),
f"HTTP {code}, callbacks={len(callbacks) if isinstance(callbacks, list) else 'n/a'}",
)
check(
"timeline includes ordinary sub-mode task flow when simulated/runtime callbacks exist",
any(str(event).startswith("task.") or str(event).startswith("handoff.") for event in event_types),
"events=" + ",".join(sorted(str(event) for event in event_types if event)[:10]),
)
check(
"timeline includes artifact or approval flow",
"artifact.created" in event_types or "approval.requested" in event_types,
"events=" + ",".join(sorted(str(event) for event in event_types if event)[:10]),
)
if FAILS:
print(f"\n{len(FAILS)} check(s) failed:")
for item in FAILS:
print(f" - {item}")
sys.exit(1)
print("\nALL AGNET SUB-MODE SMOKE CHECKS PASSED")