Files
heicode-mananger/heicode/controller/agent_template_handlers.go
T
chenchenandClaude Opus 4.8 19fd4f3326 refactor(agent): built-in per-type env mapping; fix start timeout, orphan, guards
Replace the user-facing env_map mistake with a built-in env convention keyed by
resource type + provider — users never see/edit env names; they only fill plain
resource fields. Supports git (gitea/github/gitlab), vm, database
(mysql/pg/redis/mongo, with alias normalisation), storage (azure blob / bucket).
Lenient: missing optional fields are skipped; only unsupported type or a KV read
failure errors.

Other issues found in review and fixed:
- start timeout: template-agent start now uses a longer timeout (default 60s,
  AGENT_RUNTIME_START_TIMEOUT_SECONDS) since AM provisions synchronously — 5s
  would time out. amTemplateDo takes a per-call timeout.
- orphan agent: if AM start succeeds but the Manager record fails to persist, the
  orphan is rolled back (best-effort amDeleteTemplateAgent).
- findUserTemplateAgent now guards template_id<>'' so the new endpoints can't
  touch a legacy task deployment.
- binding_ids defaults to [] (not null).
- removed ResourceBinding.EnvMap field entirely.

Tests rewritten for the built-in convention (blob metadata-only, db provider
prefixes incl pg/mg aliases, git provider-agnostic names, ownership, unsupported
type, empty); adapter round-trip + router tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:48:29 +08:00

252 lines
8.6 KiB
Go

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 req.BindingIDs == nil {
req.BindingIDs = []int{}
}
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())
// We started an agent in AM but failed to record it: roll back the
// orphan so it does not leak/keep running with no Manager record.
if strings.TrimSpace(result.RuntimeID) != "" {
if delErr := amDeleteTemplateAgent(c.Request.Context(), result.RuntimeID); delErr != nil {
common.SysLog("HeicodeDeployAgent orphan cleanup failed: " + delErr.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
}
// template_id <> '' guards against touching a legacy task deployment of the
// same user through the new template-agent endpoints.
if err := model.DB.Where("deployment_id = ? AND user_id = ? AND template_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)})
}
// refreshAgentStatus best-effort pulls the live status from AM and persists it.
// On any AM error it keeps the last-known status (never blocks the read).
func refreshAgentStatus(c *gin.Context, row *model.AgentDeployment) {
if strings.TrimSpace(row.RuntimeDeploymentID) == "" {
return
}
status, err := amGetAgentStatus(c.Request.Context(), row.RuntimeDeploymentID)
if err != nil || strings.TrimSpace(status) == "" || status == row.Status {
return
}
row.Status = status
row.UpdatedAtText = agentNow()
row.UpdatedAtMs = time.Now().UnixMilli()
if model.DB != nil {
_ = model.DB.Save(row).Error
}
}
// HeicodeGetAgent: GET /api/heicode/agents/:deployment_id
func HeicodeGetAgent(c *gin.Context) {
row, ok := findUserTemplateAgent(c)
if !ok {
return
}
refreshAgentStatus(c, &row)
common.ApiSuccess(c, templateAgentResponse(row))
}
// HeicodeGetAgentStatus: GET /api/heicode/agents/:deployment_id/status
// Pulls the live status from AM (is the agent running / crashed?).
func HeicodeGetAgentStatus(c *gin.Context) {
row, ok := findUserTemplateAgent(c)
if !ok {
return
}
refreshAgentStatus(c, &row)
common.ApiSuccess(c, gin.H{
"agent_id": row.DeploymentID,
"status": row.Status,
"updated_at": row.UpdatedAtText,
})
}
// 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"})
}