feat(agent): template-agent deploy/list/stop API (AM contract isolated)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-03 21:55:24 +08:00
co-authored by Claude Opus 4.8
parent 8ecab2c270
commit cdbc43baeb
4 changed files with 473 additions and 0 deletions
@@ -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"})
}
@@ -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
}
+96
View File
@@ -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"])
}
+14
View File
@@ -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())