fix(agent): review fixes — deployable-template gating, stopped-status guard, UI consistency
Backend: - loadAgentTemplate now requires status='active' — a known template_key can no longer deploy a template an admin deactivated (matches the client list). - refreshAgentStatus no longer lets AM's eventually-consistent live status resurrect a user-initiated "stopped" agent. - HeicodeStopAgent persists via field-level Updates (not a stale full-row Save), matching refreshAgentStatus discipline. - Drop dead amStartResult.AccessToken field (AM's token is never used; HM mints its own per-agent token). Frontend: - deploy-agent statusLabel: add the missing pending/starting → 启动中 branch so a just-deployed agent isn't shown as raw English fallback. - cockpit 最近部署: map template_id → Chinese template name (consistent with the deploy/status pages) instead of showing the raw key. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -33,6 +33,14 @@ async function listMyAgents(): Promise<AgentItem[]> {
|
||||
return res.data?.data?.items ?? []
|
||||
}
|
||||
|
||||
type TemplateItem = { template_id: string; name: string }
|
||||
async function listTemplates(): Promise<TemplateItem[]> {
|
||||
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<UserWalletData> {
|
||||
@@ -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() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<Rocket className='h-3.5 w-3.5 text-primary' />
|
||||
<span className='truncate text-sm font-medium'>
|
||||
{dep.template_id}
|
||||
{templateName(dep.template_id)}
|
||||
</span>
|
||||
<PhasePill phase={dep.status} />
|
||||
</div>
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user