diff --git a/heicode/controller/agent_template_handlers.go b/heicode/controller/agent_template_handlers.go index abbd51c..9abb7c8 100644 --- a/heicode/controller/agent_template_handlers.go +++ b/heicode/controller/agent_template_handlers.go @@ -269,6 +269,12 @@ func refreshAgentStatus(c *gin.Context, row *model.AgentDeployment) { if strings.TrimSpace(row.RuntimeDeploymentID) == "" { return } + // A user-initiated "stopped" is a terminal intent: don't let AM's eventually- + // consistent live status (which may still report running/pending while the pod + // drains) resurrect it. Only delete (which removes the row) leaves "stopped". + if strings.EqualFold(strings.TrimSpace(row.Status), "stopped") { + return + } status, err := amGetAgentStatus(c.Request.Context(), row.RuntimeDeploymentID) if err != nil || strings.TrimSpace(status) == "" || status == row.Status { return @@ -324,10 +330,16 @@ func HeicodeStopAgent(c *gin.Context) { return } } + now := agentNow() + nowMs := time.Now().UnixMilli() row.Status = "stopped" - row.UpdatedAtText = agentNow() - row.UpdatedAtMs = time.Now().UnixMilli() - if err := model.DB.Save(&row).Error; err != nil { + row.UpdatedAtText = now + row.UpdatedAtMs = nowMs + // Field-level update (not a full-row Save of a possibly-stale snapshot) — same + // discipline as refreshAgentStatus, so concurrent column writes aren't clobbered. + if err := model.DB.Model(&model.AgentDeployment{}). + Where("deployment_id = ?", row.DeploymentID). + Updates(map[string]any{"status": "stopped", "updated_at_text": now, "updated_at_ms": nowMs}).Error; err != nil { agentError(c, "DEPLOYMENT_PERSIST_FAILED", "failed to persist stop") return } diff --git a/heicode/controller/agent_template_library.go b/heicode/controller/agent_template_library.go index 402ddd0..68e6e06 100644 --- a/heicode/controller/agent_template_library.go +++ b/heicode/controller/agent_template_library.go @@ -129,14 +129,16 @@ func ensureAgentTemplatesSeeded() { }) } -// loadAgentTemplate fetches an active template by its key. +// loadAgentTemplate fetches an active template by its key. Deactivated templates +// (status != "active") are NOT deployable — same visibility as the client list, +// so a known template_key can't be used to deploy a template an admin took down. func loadAgentTemplate(key string) (model.AgentTemplate, bool) { ensureAgentTemplatesSeeded() var row model.AgentTemplate if model.DB == nil || strings.TrimSpace(key) == "" { return row, false } - if err := model.DB.Where("template_key = ?", strings.TrimSpace(key)).First(&row).Error; err != nil { + if err := model.DB.Where("template_key = ? AND status = ?", strings.TrimSpace(key), "active").First(&row).Error; err != nil { return row, false } return row, true diff --git a/heicode/controller/agent_template_runtime.go b/heicode/controller/agent_template_runtime.go index 5bf1cc9..265f047 100644 --- a/heicode/controller/agent_template_runtime.go +++ b/heicode/controller/agent_template_runtime.go @@ -47,11 +47,13 @@ func publicV1BaseURL() string { } // amStartResult is what AM returns after starting a template agent. +// Note: AM's own access_token is intentionally NOT captured here — HM mints its +// own per-agent access token (see HeicodeDeployAgent) and that is what gates the +// client↔agent connection. type amStartResult struct { - RuntimeID string - Subdomain string - AccessToken string - Status string + RuntimeID string + Subdomain string + Status string } func agentTemplateStartPath() string { @@ -199,10 +201,9 @@ func amStartTemplateAgent(ctx context.Context, args amStartArgs) (amStartResult, } } return amStartResult{ - RuntimeID: firstNonEmpty(stringFromMap(data, "runtime_id", "agent_id", "id", "name", "namespace"), name), - Subdomain: subdomain, - AccessToken: stringFromMap(data, "access_token", "token"), - Status: firstNonEmpty(stringFromMap(data, "status", "runtime_status"), "running"), + RuntimeID: firstNonEmpty(stringFromMap(data, "runtime_id", "agent_id", "id", "name", "namespace"), name), + Subdomain: subdomain, + Status: firstNonEmpty(stringFromMap(data, "status", "runtime_status"), "running"), }, nil } diff --git a/heicode/web/default/src/features/dashboard/components/cockpit/index.tsx b/heicode/web/default/src/features/dashboard/components/cockpit/index.tsx index 1c4b7b0..5b95268 100644 --- a/heicode/web/default/src/features/dashboard/components/cockpit/index.tsx +++ b/heicode/web/default/src/features/dashboard/components/cockpit/index.tsx @@ -33,6 +33,14 @@ async function listMyAgents(): Promise { return res.data?.data?.items ?? [] } +type TemplateItem = { template_id: string; name: string } +async function listTemplates(): Promise { + const res = await api.get<{ data?: { items?: TemplateItem[] } }>( + '/api/heicode/agent-templates' + ) + return res.data?.data?.items ?? [] +} + // Current user's OWN balance/usage (NOT the admin-global aggregate) — same // source the 模型与余额 page uses. async function getSelfUsage(): Promise { @@ -171,6 +179,14 @@ export function CockpitView() { retry: false, }) + const templatesQuery = useQuery({ + queryKey: ['agent-templates'], + queryFn: listTemplates, + retry: false, + }) + const templateName = (key: string) => + (templatesQuery.data ?? []).find((t) => t.template_id === key)?.name ?? key + const stats = useMemo(() => { const list: AgentItem[] = deploymentsQuery.data ?? [] let running = 0 @@ -286,7 +302,7 @@ export function CockpitView() {
- {dep.template_id} + {templateName(dep.template_id)}
diff --git a/heicode/web/default/src/features/deploy-agent/deploy-agent-page.tsx b/heicode/web/default/src/features/deploy-agent/deploy-agent-page.tsx index 156092c..e0846d5 100644 --- a/heicode/web/default/src/features/deploy-agent/deploy-agent-page.tsx +++ b/heicode/web/default/src/features/deploy-agent/deploy-agent-page.tsx @@ -61,6 +61,8 @@ function statusLabel(s: string): { text: string; cls: string } { const v = (s || '').toLowerCase() if (['running', 'active', 'ready'].includes(v)) return { text: '运行中', cls: 'bg-emerald-500/15 text-emerald-300 ring-emerald-500/30' } + if (['pending', 'starting', 'provisioning'].includes(v)) + return { text: '启动中', cls: 'bg-amber-500/15 text-amber-300 ring-amber-500/30' } if (['stopped'].includes(v)) return { text: '已停止', cls: 'bg-muted/40 text-muted-foreground ring-border' } if (['failed', 'error', 'unhealthy', 'crashed'].includes(v))