按桌面客户端统一方案 v0.1 + agent_management Sub Mode Runtime 对接,强制全量统一,不留兼容。
命名统一(强制,无兼容):
- 全仓 agnet/Agnet/AGNET → agent/Agent/AGENT:后端 Go(路由 /api/agent/*、env AGENT_*、
结构体/函数、19 个文件改名)、前端(agent-console/agent-hub、/api/agent 调用、i18n)、
DB(表 agent_*、列 agent_id)、compose/.env、文档、脚本。
- DB 加幂等迁移 renameAgnetTablesToAgent():启动时 rename 老 agnet_* 表/列,保住生产数据。
统一方案核心(10 项):
- callback 统一 /api/agent/callbacks/runtime-events(路由/广播URL/函数名)。
- artifact 兜底判定改用 Runtime 权威信号 metadata.synthesized(§7.2)+ 结构化 artifact_type。
- Manager→Runtime 路径对齐 /api/agent/sub-agile/deployments(§2.2),{deployment_id} 回退 swarm_id。
- 状态裁决 display_status:Manager 唯一裁判,completed 无有效产物→needs_codegen/
completed_without_deliverable(§10.6),接入 detail/timeline/workflow。
- GET /api/heicode/capabilities 能力发现(§6)。
- 模型策略 per_role(role_models)+ 收集 allowed_model_ids(§9)。
- resource_binding_id→secret_ref 服务端解析,客户端不再 inline secret_ref(§17.6)。
- 客户端统一路由层 /api/heicode/sub-agile|swarm/*(task≡deployment,复用控制面)+ workflow 投影。
- 日志分层 user_logs/debug_logs(§13)。
验证:go build ./... + go test(controller/router/model/middleware)全绿;前端 tsc -b + rsbuild build 通过。
待部署:VM .env 的 AGNET_*→AGENT_*;启动迁移自动 rename 表;其他三仓库需同步切到 /api/agent。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
312 lines
15 KiB
Go
312 lines
15 KiB
Go
package router
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/gin-contrib/sessions"
|
|
"github.com/gin-contrib/sessions/cookie"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/glebarez/sqlite"
|
|
"github.com/heicode/manager/common"
|
|
"github.com/heicode/manager/model"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func setupAgentRuntimeHTTPSmokeDB(t *testing.T) *gorm.DB {
|
|
t.Helper()
|
|
common.UsingSQLite = true
|
|
common.UsingMySQL = false
|
|
common.UsingPostgreSQL = false
|
|
common.RedisEnabled = false
|
|
|
|
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
|
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
model.DB = db
|
|
model.LOG_DB = db
|
|
require.NoError(t, db.AutoMigrate(
|
|
&model.User{},
|
|
&model.AgentDeployment{},
|
|
&model.AgentAuditEvent{},
|
|
))
|
|
adminToken := "runtime-smoke-admin-token"
|
|
require.NoError(t, db.Create(&model.User{
|
|
Id: 101,
|
|
Username: "runtime-smoke-admin",
|
|
Password: "not-used",
|
|
Role: common.RoleAdminUser,
|
|
Status: common.UserStatusEnabled,
|
|
DisplayName: "Runtime Smoke Admin",
|
|
AccessToken: &adminToken,
|
|
Group: "default",
|
|
}).Error)
|
|
t.Cleanup(func() {
|
|
if model.DB == db {
|
|
model.DB = nil
|
|
}
|
|
if model.LOG_DB == db {
|
|
model.LOG_DB = nil
|
|
}
|
|
sqlDB, err := db.DB()
|
|
if err == nil {
|
|
_ = sqlDB.Close()
|
|
}
|
|
})
|
|
return db
|
|
}
|
|
|
|
func startAgentRuntimeManagerSmokeServer(t *testing.T) string {
|
|
t.Helper()
|
|
gin.SetMode(gin.TestMode)
|
|
engine := gin.New()
|
|
engine.Use(sessions.Sessions("runtime-smoke", cookie.NewStore([]byte("runtime-smoke-secret"))))
|
|
SetApiRouter(engine)
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
|
require.NoError(t, err)
|
|
server := &http.Server{Handler: engine}
|
|
go func() {
|
|
_ = server.Serve(listener)
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = server.Close()
|
|
})
|
|
return "http://" + listener.Addr().String()
|
|
}
|
|
|
|
func agentRuntimeAdminRequest(t *testing.T, method string, url string, body string) *http.Response {
|
|
t.Helper()
|
|
req, err := http.NewRequest(method, url, bytes.NewBufferString(body))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Authorization", "Bearer runtime-smoke-admin-token")
|
|
req.Header.Set("New-Api-User", "101")
|
|
if body != "" {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
|
require.NoError(t, err)
|
|
return resp
|
|
}
|
|
|
|
func readAgentRuntimeSmokeBody(t *testing.T, resp *http.Response) string {
|
|
t.Helper()
|
|
defer resp.Body.Close()
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
return string(body)
|
|
}
|
|
|
|
func TestAgentRuntimeRealHTTPHealthAndShadowCreateSmoke(t *testing.T) {
|
|
db := setupAgentRuntimeHTTPSmokeDB(t)
|
|
runtimeCreateCalled := false
|
|
runtime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == http.MethodGet && r.URL.Path == "/api/agent/health":
|
|
require.Equal(t, "Bearer runtime-service-token", r.Header.Get("Authorization"))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"data":{"status":"healthy"}}`))
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/agent/sub-agile/deployments":
|
|
runtimeCreateCalled = true
|
|
require.Equal(t, "Bearer runtime-service-token", r.Header.Get("Authorization"))
|
|
require.Equal(t, "corr-http-smoke", r.Header.Get("X-Correlation-ID"))
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(body), `"sub_mode":"agile"`)
|
|
require.Contains(t, string(body), `"agile_context"`)
|
|
require.Contains(t, string(body), `"checkpoint":"ready_for_test"`)
|
|
require.Contains(t, string(body), `"manager_deployment_id"`)
|
|
require.Contains(t, string(body), `"signing_secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/callback-signing"`)
|
|
require.Contains(t, string(body), `"subscribed_events"`)
|
|
var runtimeBody map[string]any
|
|
require.NoError(t, common.Unmarshal(body, &runtimeBody))
|
|
agents := runtimeBody["agents"].([]any)
|
|
require.Equal(t, "builder", agents[0].(map[string]any)["role"])
|
|
grants := runtimeBody["resource_grants"].([]any)
|
|
require.NotEmpty(t, grants)
|
|
require.NotEmpty(t, grants[0].(map[string]any)["ref"])
|
|
metadata := runtimeBody["metadata"].(map[string]any)
|
|
require.NotEmpty(t, metadata["manager_deployment_id"])
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"data":{"deployment_id":"runtime-http-dep","swarm_id":"runtime-http-swarm","runtime_status":"accepted"}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer runtime.Close()
|
|
|
|
t.Setenv("AGENT_RUNTIME_ENABLED", "true")
|
|
t.Setenv("AGENT_RUNTIME_ASYNC", "false")
|
|
t.Setenv("AGENT_RUNTIME_BASE_URL", runtime.URL)
|
|
t.Setenv("AGENT_RUNTIME_SERVICE_TOKEN", "runtime-service-token")
|
|
t.Setenv("AGENT_RUNTIME_CALLBACK_SIGNING_SECRET_REF", "azkv://heicode-kv.vault.azure.net/secrets/callback-signing")
|
|
|
|
managerURL := startAgentRuntimeManagerSmokeServer(t)
|
|
|
|
healthResp := agentRuntimeAdminRequest(t, http.MethodGet, managerURL+"/api/agent/runtime/health", "")
|
|
healthBody := readAgentRuntimeSmokeBody(t, healthResp)
|
|
require.Equal(t, http.StatusOK, healthResp.StatusCode)
|
|
require.Contains(t, healthBody, `"success":true`)
|
|
require.Contains(t, healthBody, `"status":"healthy"`)
|
|
|
|
schemaResp, err := http.Get(managerURL + "/api/agent/callbacks/runtime-events/schema")
|
|
require.NoError(t, err)
|
|
schemaBody := readAgentRuntimeSmokeBody(t, schemaResp)
|
|
require.Equal(t, http.StatusOK, schemaResp.StatusCode)
|
|
require.Contains(t, schemaBody, `"event_type":"task.claimed"`)
|
|
require.Contains(t, schemaBody, `"event_type":"approval.requested"`)
|
|
var schemaEnvelope map[string]any
|
|
require.NoError(t, common.Unmarshal([]byte(schemaBody), &schemaEnvelope))
|
|
schemaData := schemaEnvelope["data"].(map[string]any)
|
|
schemaSecurity := schemaData["security"].(map[string]any)
|
|
require.Equal(t, "azkv://<vault>/secrets/<name>", schemaSecurity["secret_ref_scheme"])
|
|
|
|
createBody := `{
|
|
"orchestration_plan":{
|
|
"intent_id":"intent-http-smoke",
|
|
"template_hint":"heicode-task",
|
|
"objective":"real HTTP runtime smoke",
|
|
"sub_mode":"agile",
|
|
"risk_level":"low",
|
|
"budget":{"max_tokens":10000,"max_cost_usd":1,"max_duration_sec":600},
|
|
"user_context":{"user_id":"101","channel_id":"default"},
|
|
"billing_context":{"provider":"newapi","newapi_user_ref":"newapi-http-smoke"},
|
|
"agile_context":{"iteration":"2026-05-27~2026-05-28","stage":"development","checkpoint":"ready_for_test","acceptance_criteria":["接口返回成功"],"next_action":"submit_test_result","requires_user_approval":false},
|
|
"agent_runtime":{"platform":"agent","agents":[{"role":"builder","model_ref":"model-http-smoke","instance_count":1}]},
|
|
"agents":[{"role_template":"builder","goal":"smoke","default_model_id":"model-http-smoke","resource_grants":[{"grant_id":"grant-http-git","resource_id":"git-http","resource_type":"git","user_id":"101","binding_scope":"https://example.invalid/heicode/smoke.git#main","target_role":"builder","target_agent_ref":"agent-builder-1","permission_scope":["repo:read"],"metadata":{"repo_url":"https://example.invalid/heicode/smoke.git"},"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/http-smoke-git","status":"active"}]}],
|
|
"constraints":{"allowed_model_ids":["model-http-smoke"]},
|
|
"metadata":{"correlation_id":"corr-http-smoke"}
|
|
}
|
|
}`
|
|
createResp := agentRuntimeAdminRequest(t, http.MethodPost, managerURL+"/api/agent/deployments", createBody)
|
|
responseBody := readAgentRuntimeSmokeBody(t, createResp)
|
|
require.Equal(t, http.StatusOK, createResp.StatusCode)
|
|
require.Contains(t, responseBody, `"success":true`)
|
|
require.Contains(t, responseBody, `"runtime_deployment_id":"runtime-http-dep"`)
|
|
require.Contains(t, responseBody, `"runtime_swarm_id":"runtime-http-swarm"`)
|
|
require.True(t, runtimeCreateCalled)
|
|
|
|
var stored model.AgentDeployment
|
|
require.NoError(t, db.Where("runtime_swarm_id = ?", "runtime-http-swarm").First(&stored).Error)
|
|
require.Equal(t, "accepted", stored.RuntimeState)
|
|
|
|
logsResp := agentRuntimeAdminRequest(t, http.MethodGet, managerURL+"/api/agent/deployments/"+stored.DeploymentID+"/logs", "")
|
|
logsBody := readAgentRuntimeSmokeBody(t, logsResp)
|
|
require.Equal(t, http.StatusOK, logsResp.StatusCode)
|
|
require.Contains(t, logsBody, `"data_source":"manager_control_plane"`)
|
|
require.Contains(t, logsBody, `"runtime_source":"not_connected"`)
|
|
|
|
metricsResp := agentRuntimeAdminRequest(t, http.MethodGet, managerURL+"/api/agent/deployments/"+stored.DeploymentID+"/metrics", "")
|
|
metricsBody := readAgentRuntimeSmokeBody(t, metricsResp)
|
|
require.Equal(t, http.StatusOK, metricsResp.StatusCode)
|
|
require.Contains(t, metricsBody, `"data_source":"manager_control_plane"`)
|
|
require.Contains(t, metricsBody, `"platform_estimated":true`)
|
|
}
|
|
|
|
func TestSwarmRuntimeHTTPCreateUsesSwarmConfigAndPayload(t *testing.T) {
|
|
db := setupAgentRuntimeHTTPSmokeDB(t)
|
|
swarmCreateCalled := false
|
|
swarmStopCalled := false
|
|
swarm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == http.MethodGet && r.URL.Path == "/api/agent/health":
|
|
require.Equal(t, "Bearer swarm-runtime-token", r.Header.Get("Authorization"))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"data":{"status":"healthy","service":"heicode-swarm-runtime"}}`))
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/swarms":
|
|
swarmCreateCalled = true
|
|
require.Equal(t, "Bearer swarm-runtime-token", r.Header.Get("Authorization"))
|
|
require.Equal(t, "corr-swarm-smoke", r.Header.Get("X-Correlation-ID"))
|
|
body, err := io.ReadAll(r.Body)
|
|
require.NoError(t, err)
|
|
require.Contains(t, string(body), `"runtime_mode":"swarm"`)
|
|
require.Contains(t, string(body), `"manager_deployment_id"`)
|
|
var runtimeBody map[string]any
|
|
require.NoError(t, common.Unmarshal(body, &runtimeBody))
|
|
plan := runtimeBody["orchestration_plan"].(map[string]any)
|
|
agents := plan["agents"].([]any)
|
|
require.Len(t, agents, 3)
|
|
require.Equal(t, "planner", agents[0].(map[string]any)["role"])
|
|
require.Equal(t, "builder", agents[1].(map[string]any)["role"])
|
|
require.Equal(t, "reviewer", agents[2].(map[string]any)["role"])
|
|
require.NotContains(t, agents[0].(map[string]any), "role_template")
|
|
metadata := runtimeBody["metadata"].(map[string]any)
|
|
require.Equal(t, "swarm", metadata["runtime_mode"])
|
|
require.Equal(t, "api_swarms_adapter", metadata["source"])
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"data":{"deployment_id":"swarm-runtime-dep","swarm_id":"swarm-runtime-id","status":"running"}}`))
|
|
case r.Method == http.MethodPost && r.URL.Path == "/api/swarms/swarm-runtime-id/stop":
|
|
swarmStopCalled = true
|
|
require.Equal(t, "Bearer swarm-runtime-token", r.Header.Get("Authorization"))
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"success":true,"data":{"deployment_id":"swarm-runtime-dep","swarm_id":"swarm-runtime-id","status":"stopped"}}`))
|
|
default:
|
|
http.NotFound(w, r)
|
|
}
|
|
}))
|
|
defer swarm.Close()
|
|
|
|
t.Setenv("AGENT_RUNTIME_ENABLED", "false")
|
|
t.Setenv("SWARM_RUNTIME_ENABLED", "true")
|
|
t.Setenv("SWARM_RUNTIME_ASYNC", "false")
|
|
t.Setenv("SWARM_RUNTIME_BASE_URL", swarm.URL)
|
|
t.Setenv("SWARM_RUNTIME_SERVICE_TOKEN", "swarm-runtime-token")
|
|
|
|
managerURL := startAgentRuntimeManagerSmokeServer(t)
|
|
|
|
healthResp := agentRuntimeAdminRequest(t, http.MethodGet, managerURL+"/api/agent/runtime/health?mode=swarm", "")
|
|
healthBody := readAgentRuntimeSmokeBody(t, healthResp)
|
|
require.Equal(t, http.StatusOK, healthResp.StatusCode)
|
|
require.Contains(t, healthBody, `"mode":"swarm"`)
|
|
require.Contains(t, healthBody, `"status":"healthy"`)
|
|
require.Contains(t, healthBody, `"heicode-swarm-runtime"`)
|
|
|
|
createBody := `{
|
|
"orchestration_plan":{
|
|
"intent_id":"intent-swarm-smoke",
|
|
"template_hint":"heicode-swarm",
|
|
"objective":"swarm runtime smoke",
|
|
"sub_mode":"agile",
|
|
"risk_level":"low",
|
|
"budget":{"max_tokens":10000,"max_cost_usd":1,"max_duration_sec":600},
|
|
"user_context":{"user_id":"101","channel_id":"default"},
|
|
"billing_context":{"provider":"newapi","newapi_user_ref":"newapi-swarm-smoke"},
|
|
"agile_context":{"iteration":"2026-05-29","stage":"testing","checkpoint":"runtime_accepted","acceptance_criteria":["Runtime creates a swarm run"],"next_action":"submit_test_result","requires_user_approval":false},
|
|
"agent_runtime":{"platform":"agent","agents":[{"role":"planner","model_ref":"model-swarm","instance_count":1},{"role":"builder","model_ref":"model-swarm","instance_count":1},{"role":"reviewer","model_ref":"model-swarm","instance_count":1}]},
|
|
"agents":[
|
|
{"role_template":"planner","goal":"plan the swarm task","default_model_id":"model-swarm","resource_grants":[{"grant_id":"grant-swarm-plan","resource_id":"doc-swarm","resource_type":"project_doc","user_id":"101","binding_scope":"task-swarm-smoke","target_role":"planner","target_agent_ref":"agent-planner-1","permission_scope":["doc:read"],"metadata":{"resource_ref":"task-swarm-smoke"},"status":"active"}]},
|
|
{"role_template":"builder","goal":"build the swarm output","default_model_id":"model-swarm","resource_grants":[{"grant_id":"grant-swarm-build","resource_id":"git-swarm","resource_type":"git","user_id":"101","binding_scope":"task-swarm-smoke","target_role":"builder","target_agent_ref":"agent-builder-1","permission_scope":["repo:read"],"metadata":{"repo_url":"https://example.invalid/heicode/swarm.git"},"secret_ref":"azkv://heicode-kv.vault.azure.net/secrets/swarm-git","status":"active"}]},
|
|
{"role_template":"reviewer","goal":"verify the swarm output","default_model_id":"model-swarm","resource_grants":[{"grant_id":"grant-swarm-review","resource_id":"doc-swarm","resource_type":"project_doc","user_id":"101","binding_scope":"task-swarm-smoke","target_role":"reviewer","target_agent_ref":"agent-reviewer-1","permission_scope":["doc:read"],"metadata":{"resource_ref":"task-swarm-smoke"},"status":"active"}]}
|
|
],
|
|
"constraints":{"allowed_model_ids":["model-swarm"]},
|
|
"metadata":{"correlation_id":"corr-swarm-smoke"}
|
|
}
|
|
}`
|
|
createResp := agentRuntimeAdminRequest(t, http.MethodPost, managerURL+"/api/swarms", createBody)
|
|
responseBody := readAgentRuntimeSmokeBody(t, createResp)
|
|
require.Equal(t, http.StatusOK, createResp.StatusCode)
|
|
require.Contains(t, responseBody, `"success":true`)
|
|
require.Contains(t, responseBody, `"runtime_deployment_id":"swarm-runtime-dep"`)
|
|
require.Contains(t, responseBody, `"runtime_swarm_id":"swarm-runtime-id"`)
|
|
require.True(t, swarmCreateCalled)
|
|
|
|
var stored model.AgentDeployment
|
|
require.NoError(t, db.Where("runtime_swarm_id = ?", "swarm-runtime-id").First(&stored).Error)
|
|
require.Equal(t, "agile", stored.SubMode)
|
|
require.Contains(t, stored.PlanJSON, `"runtime_mode":"swarm"`)
|
|
|
|
stopResp := agentRuntimeAdminRequest(t, http.MethodPost, managerURL+"/api/agent/user/deployments/"+stored.DeploymentID+"/stop", `{"reason":"smoke done"}`)
|
|
stopBody := readAgentRuntimeSmokeBody(t, stopResp)
|
|
require.Equal(t, http.StatusOK, stopResp.StatusCode)
|
|
require.Contains(t, stopBody, `"success":true`)
|
|
require.Contains(t, stopBody, `"runtime_state":"stopped"`)
|
|
require.True(t, swarmStopCalled)
|
|
}
|