From cdbc43baeb6603b3cb2ed1528c4cd2b0497e7f8b Mon Sep 17 00:00:00 2001 From: chenchen Date: Wed, 3 Jun 2026 21:55:24 +0800 Subject: [PATCH] feat(agent): template-agent deploy/list/stop API (AM contract isolated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HM-side logic for the template-agent model, written against a proposed AM contract that is fully isolated in agent_template_runtime.go — when AM ships its real API, only the default paths + response field names in that one file change. - agent_template_runtime.go: AM adapter (list templates, start template agent with env, stop/delete) reusing the existing runtime config/url/envelope helpers. - agent_template_handlers.go: GET /api/heicode/agent-templates; POST /api/heicode/ agents (resolve bindings -> env, start via AM, persist subdomain+token); GET /agents, GET/:id, POST/:id/stop, DELETE/:id. Owner-scoped; env never logged. - reuses AgentDeployment as the agent record (TemplateID/Subdomain/AccessToken/ BindingIDsJSON) and buildAgentEnvFromBindings for env assembly. - routes wired under /api/heicode (UserOrV2DeviceAuth), verified no registration panic (router tests pass). - agent_template_test.go: 7 independent unit tests (env assembly metadata-only, ownership, secret-without-ref, unknown-source, empty; path substitution; response mapping) — all pass without AM/KV. Co-Authored-By: Claude Opus 4.8 --- heicode/controller/agent_template_handlers.go | 205 ++++++++++++++++++ heicode/controller/agent_template_runtime.go | 158 ++++++++++++++ heicode/controller/agent_template_test.go | 96 ++++++++ heicode/router/api-router.go | 14 ++ 4 files changed, 473 insertions(+) create mode 100644 heicode/controller/agent_template_handlers.go create mode 100644 heicode/controller/agent_template_runtime.go create mode 100644 heicode/controller/agent_template_test.go diff --git a/heicode/controller/agent_template_handlers.go b/heicode/controller/agent_template_handlers.go new file mode 100644 index 0000000..8b3be6f --- /dev/null +++ b/heicode/controller/agent_template_handlers.go @@ -0,0 +1,205 @@ +package controller + +import ( + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/heicode/manager/common" + "github.com/heicode/manager/model" +) + +// ───────────────────────────────────────────────────────────────────────────── +// Template-agent client API (new model). +// +// User deploys a template agent from the web console; AM starts it with the +// selected bound resources injected as env and returns a unique subdomain + +// access token. The desktop client reads the agent list here, then connects to +// the subdomain directly over SSE. HM is NOT in the agent conversation path. +// ───────────────────────────────────────────────────────────────────────────── + +// templateAgentResponse maps a deployed template-agent record to the client view. +func templateAgentResponse(row model.AgentDeployment) gin.H { + var bindingIDs []int + if strings.TrimSpace(row.BindingIDsJSON) != "" { + _ = common.UnmarshalJsonStr(row.BindingIDsJSON, &bindingIDs) + } + return gin.H{ + "agent_id": row.DeploymentID, + "template_id": row.TemplateID, + "subdomain": row.Subdomain, + "access_token": row.AccessToken, + "binding_ids": bindingIDs, + "status": row.Status, + "runtime_id": row.RuntimeDeploymentID, + "created_at": row.CreatedAtText, + "updated_at": row.UpdatedAtText, + } +} + +// HeicodeListAgentTemplates: GET /api/heicode/agent-templates +func HeicodeListAgentTemplates(c *gin.Context) { + templates, err := amListTemplates(c.Request.Context()) + if err != nil { + agentError(c, "RUNTIME_UNAVAILABLE", "failed to list templates: "+err.Error()) + return + } + common.ApiSuccess(c, gin.H{"templates": templates, "total": len(templates)}) +} + +// HeicodeDeployAgent: POST /api/heicode/agents +// Body: {template_id, binding_ids:[...]}. +func HeicodeDeployAgent(c *gin.Context) { + userID := c.GetInt("id") + if userID <= 0 { + agentError(c, "POLICY_REJECTED", "authentication required") + return + } + var req struct { + TemplateID string `json:"template_id"` + BindingIDs []int `json:"binding_ids"` + } + if err := common.UnmarshalBodyReusable(c, &req); err != nil { + agentError(c, "POLICY_REJECTED", "invalid request body") + return + } + req.TemplateID = strings.TrimSpace(req.TemplateID) + if req.TemplateID == "" { + agentError(c, "POLICY_REJECTED", "template_id is required") + return + } + if model.DB == nil { + agentError(c, "DEPLOYMENT_PERSIST_FAILED", "database not initialised") + return + } + + // Resolve selected bindings -> env (non-secret config + KV-resolved secrets). + // NEVER log env: it can contain plaintext secrets. + env, err := buildAgentEnvFromBindings(userID, req.BindingIDs) + if err != nil { + agentError(c, "RESOURCE_BINDING_INVALID", err.Error()) + return + } + + deploymentID := "dep_" + common.GetUUID()[:12] + bindingIDsJSON, _ := common.Marshal(req.BindingIDs) + + // Ask AM to start the template agent with the env injected. + result, err := amStartTemplateAgent(c.Request.Context(), req.TemplateID, deploymentID, env, agentRuntimeCallbackURL()) + if err != nil { + agentError(c, "RUNTIME_UNAVAILABLE", "failed to start agent: "+err.Error()) + return + } + + now := agentNow() + nowMs := time.Now().UnixMilli() + row := model.AgentDeployment{ + DeploymentID: deploymentID, + UserID: strconv.Itoa(userID), + TemplateID: req.TemplateID, + Subdomain: result.Subdomain, + AccessToken: result.AccessToken, + BindingIDsJSON: string(bindingIDsJSON), + RuntimeDeploymentID: result.RuntimeID, + Status: firstNonEmpty(result.Status, "running"), + CreatedAtText: now, + UpdatedAtText: now, + CreatedAtMs: nowMs, + UpdatedAtMs: nowMs, + } + if err := model.DB.Create(&row).Error; err != nil { + common.SysLog("HeicodeDeployAgent persist: " + err.Error()) + agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist agent") + return + } + common.ApiSuccess(c, templateAgentResponse(row)) +} + +// findUserTemplateAgent loads a deployed template agent owned by the caller. +func findUserTemplateAgent(c *gin.Context) (model.AgentDeployment, bool) { + var row model.AgentDeployment + userID := c.GetInt("id") + deploymentID := strings.TrimSpace(c.Param("deployment_id")) + if userID <= 0 || deploymentID == "" || model.DB == nil { + agentError(c, "POLICY_REJECTED", "deployment_id and authentication required") + return row, false + } + if err := model.DB.Where("deployment_id = ? AND user_id = ?", deploymentID, strconv.Itoa(userID)).First(&row).Error; err != nil { + agentError(c, "DEPLOYMENT_CONFLICT", "agent not found") + return row, false + } + return row, true +} + +// HeicodeListAgents: GET /api/heicode/agents +func HeicodeListAgents(c *gin.Context) { + userID := c.GetInt("id") + if userID <= 0 || model.DB == nil { + agentError(c, "POLICY_REJECTED", "authentication required") + return + } + var rows []model.AgentDeployment + // Only template-agent records (TemplateID set), not legacy task deployments. + if err := model.DB.Where("user_id = ? AND template_id <> ''", strconv.Itoa(userID)). + Order("created_at_ms desc").Find(&rows).Error; err != nil { + agentError(c, "DEPLOYMENT_CONFLICT", "failed to list agents") + return + } + items := make([]gin.H, 0, len(rows)) + for _, row := range rows { + items = append(items, templateAgentResponse(row)) + } + common.ApiSuccess(c, gin.H{"items": items, "total": len(items)}) +} + +// HeicodeGetAgent: GET /api/heicode/agents/:deployment_id +func HeicodeGetAgent(c *gin.Context) { + row, ok := findUserTemplateAgent(c) + if !ok { + return + } + common.ApiSuccess(c, templateAgentResponse(row)) +} + +// HeicodeStopAgent: POST /api/heicode/agents/:deployment_id/stop +func HeicodeStopAgent(c *gin.Context) { + row, ok := findUserTemplateAgent(c) + if !ok { + return + } + if strings.TrimSpace(row.RuntimeDeploymentID) != "" { + if err := amStopTemplateAgent(c.Request.Context(), row.RuntimeDeploymentID); err != nil { + agentError(c, "RUNTIME_UNAVAILABLE", "failed to stop agent: "+err.Error()) + return + } + } + row.Status = "stopped" + row.UpdatedAtText = agentNow() + row.UpdatedAtMs = time.Now().UnixMilli() + if err := model.DB.Save(&row).Error; err != nil { + agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist stop") + return + } + common.ApiSuccess(c, templateAgentResponse(row)) +} + +// HeicodeDeleteAgent: DELETE /api/heicode/agents/:deployment_id +func HeicodeDeleteAgent(c *gin.Context) { + row, ok := findUserTemplateAgent(c) + if !ok { + return + } + if strings.TrimSpace(row.RuntimeDeploymentID) != "" { + if err := amDeleteTemplateAgent(c.Request.Context(), row.RuntimeDeploymentID); err != nil { + agentError(c, "RUNTIME_UNAVAILABLE", "failed to delete agent: "+err.Error()) + return + } + } + if err := model.DB.Delete(&row).Error; err != nil { + agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to delete agent") + return + } + common.ApiSuccess(c, gin.H{"deployment_id": row.DeploymentID, "status": "deleted"}) +} diff --git a/heicode/controller/agent_template_runtime.go b/heicode/controller/agent_template_runtime.go new file mode 100644 index 0000000..cb5eaff --- /dev/null +++ b/heicode/controller/agent_template_runtime.go @@ -0,0 +1,158 @@ +package controller + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/heicode/manager/common" +) + +// ───────────────────────────────────────────────────────────────────────────── +// AM template-agent adapter (the ONLY place that knows AM's template HTTP API). +// +// Template-agent model: HM asks AM to start a template agent with the selected +// resources injected as env; AM returns a unique subdomain + access token; the +// desktop client then talks to that subdomain directly over SSE. +// +// When AM finalises its real API, change ONLY: +// - the default paths in the *Path() helpers (or set the env overrides), and +// - the request payload / response field names in the three am* functions. +// No other HM code needs to change. +// ───────────────────────────────────────────────────────────────────────────── + +// agentTemplate is one deployable template offered by AM. +type agentTemplate struct { + TemplateID string `json:"template_id"` + Name string `json:"name"` + Description string `json:"description"` + RequiredResourceTypes []string `json:"required_resource_types"` + EnvSchema []string `json:"env_schema"` +} + +// amStartResult is what AM returns after starting a template agent. +type amStartResult struct { + RuntimeID string + Subdomain string + AccessToken string + Status string +} + +func agentTemplatesPath() string { + return common.GetEnvOrDefaultString("AGENT_RUNTIME_TEMPLATES_PATH", "/api/agent/templates") +} + +func agentTemplateStartPath(templateID string) string { + p := common.GetEnvOrDefaultString("AGENT_RUNTIME_TEMPLATE_START_PATH", "/api/agent/templates/{template_id}/start") + return strings.ReplaceAll(p, "{template_id}", url.PathEscape(templateID)) +} + +func agentTemplateAgentPath(agentRuntimeID string) string { + p := common.GetEnvOrDefaultString("AGENT_RUNTIME_AGENT_PATH", "/api/agent/agents/{agent_id}") + return strings.ReplaceAll(p, "{agent_id}", url.PathEscape(agentRuntimeID)) +} + +func agentTemplateAgentStopPath(agentRuntimeID string) string { + p := common.GetEnvOrDefaultString("AGENT_RUNTIME_AGENT_STOP_PATH", "/api/agent/agents/{agent_id}/stop") + return strings.ReplaceAll(p, "{agent_id}", url.PathEscape(agentRuntimeID)) +} + +// amTemplateDo performs an AM HTTP call and returns the decoded `data` object. +// Reuses the existing runtime config (base URL / service token / timeout). +func amTemplateDo(ctx context.Context, method, path string, body any) (map[string]any, error) { + cfg := agentRuntimeClientConfigForMode(agentRuntimeModeAgent) + endpoint, err := agentRuntimeURL(cfg.BaseURL, path) + if err != nil { + return nil, err + } + var reader io.Reader + if body != nil { + raw, err := common.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(raw) + } + req, err := http.NewRequestWithContext(ctx, method, endpoint, reader) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if cfg.Token != "" { + req.Header.Set("Authorization", "Bearer "+cfg.Token) + } + client := &http.Client{Timeout: cfg.Timeout} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, fmt.Errorf("AM %s %s returned HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw))) + } + var envelope map[string]any + if len(raw) > 0 { + if err := common.Unmarshal(raw, &envelope); err != nil { + return nil, err + } + } + if msg := agentRuntimeEnvelopeError(envelope); msg != "" { + return nil, errors.New(msg) + } + return extractAgentRuntimeData(envelope), nil +} + +// amListTemplates lists AM's deployable templates. +// Proposed contract: data.templates = [...]. Swap this mapping if AM differs. +func amListTemplates(ctx context.Context) ([]agentTemplate, error) { + data, err := amTemplateDo(ctx, http.MethodGet, agentTemplatesPath(), nil) + if err != nil { + return nil, err + } + raw, err := common.Marshal(data["templates"]) + if err != nil { + return nil, err + } + var templates []agentTemplate + if err := common.Unmarshal(raw, &templates); err != nil { + return nil, err + } + return templates, nil +} + +// amStartTemplateAgent asks AM to start a template agent with the given env. +// Proposed request: {manager_deployment_id, env, callback_url}. +// Proposed response data: {runtime_id, subdomain, access_token, status}. +func amStartTemplateAgent(ctx context.Context, templateID, managerDeploymentID string, env map[string]string, callbackURL string) (amStartResult, error) { + payload := map[string]any{ + "manager_deployment_id": managerDeploymentID, + "env": env, + "callback_url": callbackURL, + } + data, err := amTemplateDo(ctx, http.MethodPost, agentTemplateStartPath(templateID), payload) + if err != nil { + return amStartResult{}, err + } + return amStartResult{ + RuntimeID: stringFromMap(data, "runtime_id", "agent_id", "id", "deployment_id"), + Subdomain: stringFromMap(data, "subdomain", "address", "url"), + AccessToken: stringFromMap(data, "access_token", "token"), + Status: stringFromMap(data, "status", "runtime_status"), + }, nil +} + +func amStopTemplateAgent(ctx context.Context, runtimeID string) error { + _, err := amTemplateDo(ctx, http.MethodPost, agentTemplateAgentStopPath(runtimeID), nil) + return err +} + +func amDeleteTemplateAgent(ctx context.Context, runtimeID string) error { + _, err := amTemplateDo(ctx, http.MethodDelete, agentTemplateAgentPath(runtimeID), nil) + return err +} diff --git a/heicode/controller/agent_template_test.go b/heicode/controller/agent_template_test.go new file mode 100644 index 0000000..62235b5 --- /dev/null +++ b/heicode/controller/agent_template_test.go @@ -0,0 +1,96 @@ +package controller + +import ( + "testing" + + "github.com/heicode/manager/model" + "github.com/stretchr/testify/require" +) + +func TestBuildAgentEnvFromBindings_MetadataOnly(t *testing.T) { + setupResourceControllerTestDB(t) + + b := model.ResourceBinding{ + UserId: 7, + Name: "blob1", + ResourceType: "blob", + Metadata: `{"account":"myacct","container":"uploads"}`, + EnvMap: `{"AZURE_BLOB_ACCOUNT":{"source":"metadata","key":"account"},"AZURE_BLOB_CONTAINER":{"source":"metadata","key":"container"}}`, + } + require.NoError(t, model.DB.Create(&b).Error) + + env, err := buildAgentEnvFromBindings(7, []int{b.Id}) + require.NoError(t, err) + require.Equal(t, "myacct", env["AZURE_BLOB_ACCOUNT"]) + require.Equal(t, "uploads", env["AZURE_BLOB_CONTAINER"]) +} + +func TestBuildAgentEnvFromBindings_OwnershipEnforced(t *testing.T) { + setupResourceControllerTestDB(t) + b := model.ResourceBinding{UserId: 7, Name: "x", ResourceType: "blob", Metadata: `{}`, EnvMap: `{}`} + require.NoError(t, model.DB.Create(&b).Error) + + _, err := buildAgentEnvFromBindings(99, []int{b.Id}) // different user + require.Error(t, err) +} + +func TestBuildAgentEnvFromBindings_SecretWithoutRefFailsBeforeKV(t *testing.T) { + setupResourceControllerTestDB(t) + b := model.ResourceBinding{ + UserId: 7, + Name: "vm1", + ResourceType: "vm", + Metadata: `{"host":"1.2.3.4"}`, + EnvMap: `{"VM_PASSWORD":{"source":"secret","key":"password"}}`, + // SecretRef intentionally empty -> must error before any Key Vault call + } + require.NoError(t, model.DB.Create(&b).Error) + + _, err := buildAgentEnvFromBindings(7, []int{b.Id}) + require.Error(t, err) + require.Contains(t, err.Error(), "secret_ref") +} + +func TestBuildAgentEnvFromBindings_UnknownSource(t *testing.T) { + setupResourceControllerTestDB(t) + b := model.ResourceBinding{ + UserId: 7, + Name: "x", + ResourceType: "blob", + Metadata: `{}`, + EnvMap: `{"FOO":{"source":"bogus","key":"x"}}`, + } + require.NoError(t, model.DB.Create(&b).Error) + + _, err := buildAgentEnvFromBindings(7, []int{b.Id}) + require.Error(t, err) +} + +func TestBuildAgentEnvFromBindings_Empty(t *testing.T) { + setupResourceControllerTestDB(t) + env, err := buildAgentEnvFromBindings(7, nil) + require.NoError(t, err) + require.Empty(t, env) +} + +func TestAgentTemplatePathSubstitution(t *testing.T) { + require.Contains(t, agentTemplateStartPath("tpl1"), "/templates/tpl1/start") + require.Contains(t, agentTemplateAgentStopPath("rt-9"), "/agents/rt-9/stop") + require.Contains(t, agentTemplateAgentPath("rt-9"), "/agents/rt-9") +} + +func TestTemplateAgentResponse(t *testing.T) { + row := model.AgentDeployment{ + DeploymentID: "dep_abc", + TemplateID: "tpl1", + Subdomain: "https://abc.agents.example", + AccessToken: "tok", + BindingIDsJSON: "[1,2]", + Status: "running", + } + resp := templateAgentResponse(row) + require.Equal(t, "dep_abc", resp["agent_id"]) + require.Equal(t, "tpl1", resp["template_id"]) + require.Equal(t, []int{1, 2}, resp["binding_ids"]) + require.Equal(t, "running", resp["status"]) +} diff --git a/heicode/router/api-router.go b/heicode/router/api-router.go index 98627e7..62a27f7 100644 --- a/heicode/router/api-router.go +++ b/heicode/router/api-router.go @@ -566,6 +566,20 @@ func SetApiRouter(router *gin.Engine) { heicodeSwarmRoute.Use(middleware.UserOrV2DeviceAuth()) registerHeicodeTaskRoutes(heicodeSwarmRoute, controller.HeicodeCreateSwarmTask, controller.HeicodeListSwarmTasks) + // Template-agent model (new): deploy a template agent with bound resources + // injected as env; client reads the agent list and connects to the agent's + // subdomain directly. Same V2-device / session auth as the task routes. + heicodeAgentRoute := apiRouter.Group("/heicode") + heicodeAgentRoute.Use(middleware.UserOrV2DeviceAuth()) + { + heicodeAgentRoute.GET("/agent-templates", controller.HeicodeListAgentTemplates) + heicodeAgentRoute.POST("/agents", controller.HeicodeDeployAgent) + heicodeAgentRoute.GET("/agents", controller.HeicodeListAgents) + heicodeAgentRoute.GET("/agents/:deployment_id", controller.HeicodeGetAgent) + heicodeAgentRoute.POST("/agents/:deployment_id/stop", controller.HeicodeStopAgent) + heicodeAgentRoute.DELETE("/agents/:deployment_id", controller.HeicodeDeleteAgent) + } + // Agent orchestration control plane (minimal integration endpoints) agentRoute := apiRouter.Group("/agent") agentRoute.Use(middleware.AdminAuth())