Files
heicode-mananger/heicode/controller/agent_runtime_client.go
T
chenchenandClaude Opus 4.8 2c60af0216 feat(swarm): #46/#28 暴露 input/result 代理端点(I/O 闭环)
agent_swarm PR#41 已合并 main(契约冻结),HM 补齐客户端+swarm 双方点名需要的
两条透传端点(均设备签名,挂 /api/heicode/swarms/:id 下):

- POST /swarms/:id/input  → 代理 Swarm POST …/{deployment_id}/input(追加用户输入,
  注入 source=user_append;终态 run 自动 reopen,stopped 拒绝)。写路,需
  SWARM_RUNTIME_ENABLED+base+token,缺一即拒不伪造受理。**指令文本仅转发,绝不落
  HM 日志/审计/事件**(#40 原文不进事件流 + #46 勿落日志);审计只记发生过一次 append。
- GET  /swarms/:id/result → 代理 Swarm GET …/{deployment_id}/result,返回
  {summary, deliverable, artifacts[], termination_reason, status};产物按 uri 取非内联。
  读路,需 base+token(与派发开关解耦);透出前 stripSensitiveKeys 递归剔除 secret_ref 等兜底。

新增:callSwarmRuntimeJSON(通用 JSON 调用 + envelope 归一,错误信息不含请求体)、
agentRuntimeSwarm{Input,Result}Path(默认 create-path 基路径,SWARM_RUNTIME_{INPUT,RESULT}_PATH 可覆盖)。

测试:swarm_io_proxy_test.go 覆盖路径构造(默认/空回退/env 覆盖)、input 转发(方法/路径/鉴权/body)、
result 解析 + deliverable/artifacts 脱敏、运行时非 2xx 报错。go build ./... + controller/router 全绿。

影响面:Manager ✅(透传端点)/ agent_swarm ✅(消费 PR#41 契约)/ Client ✅(驾驶舱 I/O 闭环)/
Agent ❌ / CodeGW ❌ / 计费 ❌ / 密钥 ❌(不经手 secret)/ 审计 ✅(input_appended,不含原文)/ 发布链路 ❌

Refs #46 #28。Depends-on-contract: agent_swarm PR#41 (已合并 main)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 18:41:39 +08:00

1400 lines
50 KiB
Go

package controller
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
)
const (
agentRuntimeStateSyncing = "runtime_syncing"
agentRuntimeStateSynced = "runtime_accepted"
agentRuntimeStateFailed = "runtime_sync_failed"
agentRuntimeModeAgent = "agent"
agentRuntimeModeSwarm = "swarm"
)
type agentRuntimeConfig struct {
Enabled bool
Async bool
BaseURL string
Token string
CreatePath string
HealthPath string
StatusPath string
ArtifactContentPath string
StopPath string
ApprovalDecisionPath string
Timeout time.Duration
}
type agentRuntimeSyncResult struct {
RuntimeDeploymentID string
RuntimeSwarmID string
RuntimeStatus string
RawStatusCode int
}
type agentRuntimeDiagnostics struct {
DeploymentID string `json:"deployment_id"`
RuntimeMode string `json:"runtime_mode"`
SubMode string `json:"sub_mode"`
RuntimeDeploymentID string `json:"runtime_deployment_id,omitempty"`
RuntimeSwarmID string `json:"runtime_swarm_id,omitempty"`
DataSource string `json:"data_source"`
HTTPStatus int `json:"http_status,omitempty"`
Status string `json:"status,omitempty"`
Phase string `json:"phase,omitempty"`
Progress any `json:"progress,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
Agents []gin.H `json:"agents"`
Artifacts []gin.H `json:"artifacts"`
Metrics map[string]any `json:"metrics,omitempty"`
Warnings []string `json:"warnings"`
CheckedAt string `json:"checked_at"`
}
func normalizeAgentRuntimeMode(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case agentRuntimeModeSwarm:
return agentRuntimeModeSwarm
default:
return agentRuntimeModeAgent
}
}
func agentRuntimeModeForSource(source string) string {
if strings.TrimSpace(source) == "api_swarms_adapter" {
return agentRuntimeModeSwarm
}
return agentRuntimeModeAgent
}
func agentRuntimeModeForRecord(record agentDeploymentRecord) string {
return normalizeAgentRuntimeMode(record.Plan.Metadata.RuntimeMode)
}
func agentRuntimeClientConfig() agentRuntimeConfig {
return agentRuntimeClientConfigForMode(agentRuntimeModeAgent)
}
func agentRuntimeClientConfigForMode(mode string) agentRuntimeConfig {
timeoutSec := common.GetEnvOrDefault("AGENT_RUNTIME_TIMEOUT_SECONDS", 5)
if timeoutSec <= 0 {
timeoutSec = 5
}
mode = normalizeAgentRuntimeMode(mode)
prefix := "AGENT_RUNTIME_"
// Sub Agile primary route per agent_management Sub Mode Runtime §2.2.
defaultCreatePath := "/api/agent/sub-agile/deployments"
defaultStopPath := "/api/agent/sub-agile/deployments/{deployment_id}/stop"
defaultApprovalPath := "/api/swarms/{swarm_id}/approvals/{approval_id}"
defaultStatusPath := "/api/swarms/{swarm_id}/status"
defaultArtifactContentPath := "/api/swarms/{swarm_id}/artifacts/{artifact_id}/content"
if mode == agentRuntimeModeSwarm {
prefix = "SWARM_RUNTIME_"
// HeiCode-Swarm standard routes (蜂群对接文档 §3.1). The runtime has no
// /status subpath — deployment status is read from the deployment detail
// endpoint. {deployment_id} resolves to the runtime deployment id.
// Legacy /api/swarms/* still works on the runtime but is no longer the
// Manager default. (sub-agile defaults above are untouched.)
defaultCreatePath = "/api/agent/swarm/deployments"
defaultStopPath = "/api/agent/swarm/deployments/{deployment_id}/stop"
defaultApprovalPath = "/api/agent/swarm/deployments/{deployment_id}/approvals/{approval_id}"
defaultStatusPath = "/api/agent/swarm/deployments/{deployment_id}"
if swarmTimeout := common.GetEnvOrDefault("SWARM_RUNTIME_TIMEOUT_SECONDS", timeoutSec); swarmTimeout > 0 {
timeoutSec = swarmTimeout
}
}
baseURL := strings.TrimRight(strings.TrimSpace(common.GetEnvOrDefaultString(prefix+"BASE_URL", "")), "/")
enabledDefault := false
if mode == agentRuntimeModeAgent {
enabledDefault = common.GetEnvOrDefaultBool("AGENT_RUNTIME_ENABLED", false)
} else {
enabledDefault = baseURL != ""
}
return agentRuntimeConfig{
Enabled: common.GetEnvOrDefaultBool(prefix+"ENABLED", enabledDefault),
Async: common.GetEnvOrDefaultBool(prefix+"ASYNC", common.GetEnvOrDefaultBool("AGENT_RUNTIME_ASYNC", true)),
BaseURL: baseURL,
Token: strings.TrimSpace(common.GetEnvOrDefaultString(prefix+"SERVICE_TOKEN", "")),
CreatePath: common.GetEnvOrDefaultString(prefix+"CREATE_PATH", defaultCreatePath),
HealthPath: common.GetEnvOrDefaultString(prefix+"HEALTH_PATH", "/api/agent/health"),
StatusPath: common.GetEnvOrDefaultString(prefix+"STATUS_PATH", defaultStatusPath),
ArtifactContentPath: common.GetEnvOrDefaultString(prefix+"ARTIFACT_CONTENT_PATH", defaultArtifactContentPath),
StopPath: common.GetEnvOrDefaultString(prefix+"STOP_PATH", defaultStopPath),
ApprovalDecisionPath: common.GetEnvOrDefaultString(prefix+"APPROVAL_DECISION_PATH", defaultApprovalPath),
Timeout: time.Duration(timeoutSec) * time.Second,
}
}
func agentRuntimeCallbackURL() string {
if value := strings.TrimSpace(common.GetEnvOrDefaultString("AGENT_RUNTIME_CALLBACK_URL", "")); value != "" {
return value
}
baseURL := strings.TrimRight(strings.TrimSpace(common.GetEnvOrDefaultString("HEICODE_PUBLIC_BASE_URL", "https://code.xinghanlab.com")), "/")
return baseURL + "/api/agent/callbacks/runtime-events"
}
func agentRuntimeCallbackSigningSecretRef() string {
return strings.TrimSpace(common.GetEnvOrDefaultString("AGENT_RUNTIME_CALLBACK_SIGNING_SECRET_REF", ""))
}
func agentRuntimeSubscribedEvents() []string {
return []string{
"deployment.status_changed",
"phase.changed",
"agent.started",
"agent.completed",
"agent.crashed",
"sk_tool.called",
"sk_tool.completed",
"sk_tool.failed",
"approval.requested",
"budget.alert",
"artifact.created",
"timeline.updated",
"task.created",
"task.claimed",
"task.running",
"task.heartbeat",
"task.blocked",
"task.retried",
"task.failed",
"task.completed",
"handoff.requested",
"handoff.completed",
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func truncateAgentFailureReason(value string) string {
value = strings.TrimSpace(value)
if len(value) <= 480 {
return value
}
return value[:480]
}
func agentRuntimeURL(baseURL string, path string) (string, error) {
if strings.TrimSpace(baseURL) == "" {
return "", errors.New("AGENT_RUNTIME_BASE_URL is not configured")
}
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", errors.New("AGENT_RUNTIME_BASE_URL must be an absolute http(s) URL")
}
if strings.TrimSpace(path) == "" {
path = "/"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return strings.TrimRight(baseURL, "/") + path, nil
}
func agentRuntimeHeaders(req *http.Request, cfg agentRuntimeConfig, record agentDeploymentRecord) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-User-ID", record.Plan.UserContext.UserID)
req.Header.Set("X-Binding-Scope", firstPlanBindingScope(record.Plan))
req.Header.Set("X-Correlation-ID", record.Plan.Metadata.CorrelationID)
req.Header.Set("X-Idempotency-Key", "manager-"+record.DeploymentID)
if cfg.Token != "" {
req.Header.Set("Authorization", "Bearer "+cfg.Token)
}
}
func agentRuntimeRequestAgents(plan agentOrchestrationPlan) []gin.H {
byRole := make(map[string]agentAgentPlan, len(plan.Agents))
for _, agent := range plan.Agents {
role := strings.TrimSpace(agent.RoleTemplate)
if role != "" {
byRole[role] = agent
}
}
items := make([]gin.H, 0, len(plan.AgentRuntime.Agents))
for _, runtimeAgent := range plan.AgentRuntime.Agents {
role := strings.TrimSpace(runtimeAgent.Role)
if role == "" {
continue
}
item := gin.H{"role": role}
if agent, ok := byRole[role]; ok {
if len(agent.SKSources) > 0 {
item["sk_sources"] = agent.SKSources
}
if len(agent.ResourceGrants) > 0 {
item["resource_grants"] = agentRuntimeResourceGrantPayloads(agent.ResourceGrants)
}
}
items = append(items, item)
}
if len(items) > 0 {
return items
}
for _, agent := range plan.Agents {
role := strings.TrimSpace(agent.RoleTemplate)
if role == "" {
continue
}
item := gin.H{"role": role}
if len(agent.SKSources) > 0 {
item["sk_sources"] = agent.SKSources
}
if len(agent.ResourceGrants) > 0 {
item["resource_grants"] = agentRuntimeResourceGrantPayloads(agent.ResourceGrants)
}
items = append(items, item)
}
return items
}
func agentRuntimeRequestSwarmAgents(plan agentOrchestrationPlan) []gin.H {
runtimeModels := make(map[string]string, len(plan.AgentRuntime.Agents))
for _, runtimeAgent := range plan.AgentRuntime.Agents {
role := strings.TrimSpace(runtimeAgent.Role)
if role != "" && strings.TrimSpace(runtimeAgent.ModelRef) != "" {
runtimeModels[role] = strings.TrimSpace(runtimeAgent.ModelRef)
}
}
items := make([]gin.H, 0, len(plan.Agents))
for index, agent := range plan.Agents {
role := strings.TrimSpace(agent.RoleTemplate)
if role == "" {
continue
}
taskID := fmt.Sprintf("%s-%d", sanitizeAgentRef(role), index+1)
item := gin.H{
"task_id": taskID,
"role": role,
"title": role + " task",
"description": firstNonEmpty(agent.Goal, "Execute the Heicode swarm task as "+role),
"depends_on": []string{},
}
if modelRef := firstNonEmpty(runtimeModels[role], agent.DefaultModelID); modelRef != "" {
item["model_ref"] = modelRef
}
if len(agent.SKSources) > 0 {
item["sk_sources"] = agent.SKSources
}
if len(agent.ResourceGrants) > 0 {
item["resource_grants"] = agentRuntimeResourceGrantPayloads(agent.ResourceGrants)
}
items = append(items, item)
}
return items
}
func agentRuntimeResourceGrantPayloads(grants []agentResourceGrant) []gin.H {
items := make([]gin.H, 0, len(grants))
for _, grant := range grants {
secretRef := strings.TrimSpace(grant.SecretRef)
if secretRef == "" {
continue
}
item := gin.H{
"grant_id": grant.GrantID,
"resource_id": grant.ResourceID,
"resource_type": grant.ResourceType,
"type": grant.ResourceType,
"user_id": grant.UserID,
"binding_scope": grant.BindingScope,
"target_role": grant.TargetRole,
"target_agent_ref": grant.TargetAgentRef,
"permission_scope": grant.PermissionScope,
"permissions": grant.PermissionScope,
"status": grant.Status,
"ref": secretRef,
"secret_ref": secretRef,
"allowed_ref": agentGrantResourceRef(grant),
"resource_ref_hint": agentGrantResourceRef(grant),
}
if grant.TenantID != "" {
item["tenant_id"] = grant.TenantID
}
if grant.ProjectID != "" {
item["project_id"] = grant.ProjectID
}
if len(grant.Constraints) > 0 {
item["constraints"] = grant.Constraints
}
if len(grant.Metadata) > 0 {
item["metadata"] = grant.Metadata
}
if len(grant.Audit) > 0 {
item["audit"] = grant.Audit
}
items = append(items, item)
}
return items
}
func agentRuntimeRequestResourceGrants(plan agentOrchestrationPlan) []gin.H {
items := make([]agentResourceGrant, 0, len(plan.ResourceGrants))
items = append(items, plan.ResourceGrants...)
for _, agent := range plan.Agents {
items = append(items, agent.ResourceGrants...)
}
return agentRuntimeResourceGrantPayloads(items)
}
func agentRuntimeRequestMetadata(record agentDeploymentRecord, source string) gin.H {
metadata := gin.H{
"correlation_id": record.Plan.Metadata.CorrelationID,
"manager_deployment_id": record.DeploymentID,
"source": source,
"heicode_deployment_id": record.DeploymentID,
"heicode_runtime_bridge": true,
"runtime_mode": agentRuntimeModeForRecord(record),
}
if record.Plan.Metadata.TenantID != "" {
metadata["tenant_id"] = record.Plan.Metadata.TenantID
}
if record.Plan.Metadata.ProjectID != "" {
metadata["project_id"] = record.Plan.Metadata.ProjectID
}
return metadata
}
func agentRuntimeBudgetPayload(budget agentBudget) gin.H {
return gin.H{
"max_tokens": budget.MaxTokens,
"token_limit": budget.MaxTokens,
"max_cost_usd": budget.MaxCostUSD,
"max_duration_sec": budget.MaxDurationSec,
"duration_seconds": budget.MaxDurationSec,
"max_duration_seconds": budget.MaxDurationSec,
}
}
func agentRuntimeOrchestrationPlanPayload(record agentDeploymentRecord) any {
if agentRuntimeModeForRecord(record) != agentRuntimeModeSwarm {
return record.Plan
}
plan := record.Plan
return gin.H{
"intent_id": plan.IntentID,
"template_hint": plan.TemplateHint,
"objective": plan.Objective,
"sub_mode": firstNonEmpty(plan.SubMode, "goal_driven_swarm"),
"risk_level": plan.RiskLevel,
"budget": agentRuntimeBudgetPayload(plan.Budget),
"user_context": plan.UserContext,
"billing_context": plan.BillingContext,
"agile_context": plan.AgileContext,
"agents": agentRuntimeRequestSwarmAgents(plan),
"resource_grants": agentRuntimeRequestResourceGrants(plan),
"constraints": plan.Constraints,
"metadata": agentRuntimeRequestMetadata(record, "orchestration_plan"),
"agent_runtime": plan.AgentRuntime,
"acceptance": plan.AgileContext.AcceptanceCriteria,
"acceptance_tests": plan.AgileContext.AcceptanceCriteria,
}
}
func agentRuntimeCreatePayload(record agentDeploymentRecord, source string) gin.H {
callback := gin.H{
"url": agentRuntimeCallbackURL(),
"subscribed_events": agentRuntimeSubscribedEvents(),
}
if ref := agentRuntimeCallbackSigningSecretRef(); ref != "" {
callback["signing_secret_ref"] = ref
}
return gin.H{
"orchestration_plan": agentRuntimeOrchestrationPlanPayload(record),
"agents": agentRuntimeRequestAgents(record.Plan),
"risk_level": record.Plan.RiskLevel,
"budget": agentRuntimeBudgetPayload(record.Plan.Budget),
"billing_context": record.Plan.BillingContext,
"resource_grants": agentRuntimeRequestResourceGrants(record.Plan),
"callback": callback,
"agile_context": record.Plan.AgileContext,
"sub_mode": record.Plan.SubMode,
"metadata": agentRuntimeRequestMetadata(record, source),
}
}
func extractAgentRuntimeData(payload map[string]any) map[string]any {
if data, ok := payload["data"].(map[string]any); ok {
return data
}
return payload
}
func agentRuntimeEnvelopeError(payload map[string]any) string {
success, hasSuccess := payload["success"].(bool)
if !hasSuccess || success {
return ""
}
if errPayload, ok := payload["error"].(map[string]any); ok {
return firstNonEmpty(stringFromMap(errPayload, "message"), stringFromMap(errPayload, "code"), "runtime returned success=false")
}
return "runtime returned success=false"
}
func stringFromMap(values map[string]any, keys ...string) string {
for _, key := range keys {
if value, ok := values[key]; ok {
switch typed := value.(type) {
case string:
if strings.TrimSpace(typed) != "" {
return strings.TrimSpace(typed)
}
case fmt.Stringer:
if strings.TrimSpace(typed.String()) != "" {
return strings.TrimSpace(typed.String())
}
}
}
}
return ""
}
func callAgentRuntimeCreate(ctx context.Context, cfg agentRuntimeConfig, record agentDeploymentRecord, source string) (agentRuntimeSyncResult, error) {
endpoint, err := agentRuntimeURL(cfg.BaseURL, cfg.CreatePath)
if err != nil {
return agentRuntimeSyncResult{}, err
}
payload, err := common.Marshal(agentRuntimeCreatePayload(record, source))
if err != nil {
return agentRuntimeSyncResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return agentRuntimeSyncResult{}, err
}
agentRuntimeHeaders(req, cfg, record)
client := &http.Client{Timeout: cfg.Timeout}
resp, err := client.Do(req)
if err != nil {
return agentRuntimeSyncResult{}, err
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return agentRuntimeSyncResult{}, readErr
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return agentRuntimeSyncResult{RawStatusCode: resp.StatusCode}, fmt.Errorf("runtime create returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var envelope map[string]any
if len(body) > 0 {
if err := common.Unmarshal(body, &envelope); err != nil {
return agentRuntimeSyncResult{RawStatusCode: resp.StatusCode}, err
}
}
if message := agentRuntimeEnvelopeError(envelope); message != "" {
return agentRuntimeSyncResult{RawStatusCode: resp.StatusCode}, errors.New(message)
}
data := extractAgentRuntimeData(envelope)
result := agentRuntimeSyncResult{
RuntimeDeploymentID: stringFromMap(data, "runtime_deployment_id", "deployment_id", "id"),
RuntimeSwarmID: stringFromMap(data, "swarm_id", "runtime_swarm_id"),
RuntimeStatus: stringFromMap(data, "runtime_status", "status"),
RawStatusCode: resp.StatusCode,
}
return result, nil
}
func agentRuntimeStopPath(cfg agentRuntimeConfig, runtimeDeploymentID string) string {
path := strings.TrimSpace(cfg.StopPath)
if path == "" {
path = "/api/agent/deployments/{deployment_id}/stop"
}
return strings.ReplaceAll(path, "{deployment_id}", url.PathEscape(runtimeDeploymentID))
}
// agentRuntimeSwarmInputPath / agentRuntimeSwarmResultPath 构造 swarm 运行时的追加输入 / 结果
// 端点(agent_swarm PR#41 runtime-contract:POST/GET …/{deployment_id}/{input,result})。默认沿用
// create-path 的 /api/agent/swarm/deployments 基路径,可经 SWARM_RUNTIME_{INPUT,RESULT}_PATH 覆盖。
func agentRuntimeSwarmInputPath(cfg agentRuntimeConfig, runtimeDeploymentID string) string {
return swarmRuntimeDeploymentSubPath(cfg, runtimeDeploymentID, "input", "SWARM_RUNTIME_INPUT_PATH")
}
func agentRuntimeSwarmResultPath(cfg agentRuntimeConfig, runtimeDeploymentID string) string {
return swarmRuntimeDeploymentSubPath(cfg, runtimeDeploymentID, "result", "SWARM_RUNTIME_RESULT_PATH")
}
func swarmRuntimeDeploymentSubPath(cfg agentRuntimeConfig, runtimeDeploymentID, sub, envKey string) string {
path := strings.TrimSpace(common.GetEnvOrDefaultString(envKey, ""))
if path == "" {
base := strings.TrimRight(strings.TrimSpace(cfg.CreatePath), "/")
if base == "" {
base = "/api/agent/swarm/deployments"
}
path = base + "/{deployment_id}/" + sub
}
return strings.ReplaceAll(path, "{deployment_id}", url.PathEscape(strings.TrimSpace(runtimeDeploymentID)))
}
func agentRuntimeStopPathForRecord(cfg agentRuntimeConfig, record agentDeploymentRecord) string {
// {deployment_id} falls back to the swarm id so sub-agile-style paths still
// resolve for records that only persisted a runtime swarm id.
path := agentRuntimeStopPath(cfg, firstNonEmpty(record.RuntimeDeploymentID, record.RuntimeSwarmID))
replacer := strings.NewReplacer(
"{swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{runtime_swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{runtime_deployment_id}", url.PathEscape(strings.TrimSpace(record.RuntimeDeploymentID)),
"{manager_deployment_id}", url.PathEscape(strings.TrimSpace(record.DeploymentID)),
)
return replacer.Replace(path)
}
func agentRuntimeStatusPathForRecord(cfg agentRuntimeConfig, record agentDeploymentRecord) string {
path := strings.TrimSpace(cfg.StatusPath)
if path == "" {
path = "/api/swarms/{swarm_id}/status"
}
replacer := strings.NewReplacer(
"{swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{runtime_swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{deployment_id}", url.PathEscape(strings.TrimSpace(record.RuntimeDeploymentID)),
"{runtime_deployment_id}", url.PathEscape(strings.TrimSpace(record.RuntimeDeploymentID)),
"{manager_deployment_id}", url.PathEscape(strings.TrimSpace(record.DeploymentID)),
)
return replacer.Replace(path)
}
func agentRuntimeArtifactContentPathForRecord(cfg agentRuntimeConfig, record agentDeploymentRecord, artifactID string) string {
path := strings.TrimSpace(cfg.ArtifactContentPath)
if path == "" {
path = "/api/swarms/{swarm_id}/artifacts/{artifact_id}/content"
}
replacer := strings.NewReplacer(
"{swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{runtime_swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{deployment_id}", url.PathEscape(strings.TrimSpace(record.RuntimeDeploymentID)),
"{runtime_deployment_id}", url.PathEscape(strings.TrimSpace(record.RuntimeDeploymentID)),
"{manager_deployment_id}", url.PathEscape(strings.TrimSpace(record.DeploymentID)),
"{artifact_id}", url.PathEscape(strings.TrimSpace(artifactID)),
)
return replacer.Replace(path)
}
func callAgentRuntimeStatus(ctx context.Context, cfg agentRuntimeConfig, record agentDeploymentRecord) (map[string]any, int, error) {
if strings.TrimSpace(record.RuntimeSwarmID) == "" && strings.TrimSpace(record.RuntimeDeploymentID) == "" {
return nil, 0, errors.New("runtime identifiers missing")
}
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeStatusPathForRecord(cfg, record))
if err != nil {
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, 0, err
}
agentRuntimeHeaders(req, cfg, record)
client := &http.Client{Timeout: cfg.Timeout}
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return nil, resp.StatusCode, readErr
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, resp.StatusCode, fmt.Errorf("runtime status returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var envelope map[string]any
if len(body) > 0 {
if err := common.Unmarshal(body, &envelope); err != nil {
return nil, resp.StatusCode, err
}
}
if message := agentRuntimeEnvelopeError(envelope); message != "" {
return nil, resp.StatusCode, errors.New(message)
}
return extractAgentRuntimeData(envelope), resp.StatusCode, nil
}
func callAgentRuntimeArtifactContent(ctx context.Context, cfg agentRuntimeConfig, record agentDeploymentRecord, artifactID string) (*http.Response, error) {
if strings.TrimSpace(artifactID) == "" {
return nil, errors.New("artifact_id is required")
}
if strings.TrimSpace(record.RuntimeSwarmID) == "" && strings.TrimSpace(record.RuntimeDeploymentID) == "" {
return nil, errors.New("runtime identifiers missing")
}
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeArtifactContentPathForRecord(cfg, record, artifactID))
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
agentRuntimeHeaders(req, cfg, record)
req.Header.Set("Accept", "*/*")
client := &http.Client{Timeout: cfg.Timeout}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
return resp, nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("runtime artifact content returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
func mapSliceFromAny(value any) []gin.H {
raw, ok := value.([]any)
if !ok {
return nil
}
items := make([]gin.H, 0, len(raw))
for _, entry := range raw {
if item, ok := entry.(map[string]any); ok {
items = append(items, gin.H(item))
}
}
return items
}
func mapFromAny(value any) map[string]any {
if item, ok := value.(map[string]any); ok {
return item
}
return nil
}
func runtimeAgentHasFailed(agents []gin.H) bool {
for _, agent := range agents {
status := strings.ToLower(strings.TrimSpace(fmt.Sprint(agent["status"])))
if strings.Contains(status, "fail") ||
strings.Contains(status, "crash") ||
strings.Contains(status, "error") {
return true
}
}
return false
}
// deliverableArtifactTypes are Runtime artifact_type values that represent a
// real business deliverable (code, tests, deploy manifest). Anything outside
// this set with no file-change signal is treated as a non-deliverable summary.
var deliverableArtifactTypes = map[string]bool{
"code_patch": true,
"code_bundle": true,
"code_document": true,
"deployment_manifest": true,
"test_report": true,
"diff": true,
"patch": true,
}
// artifactHasFileChanges reports whether the artifact carries a structured
// signal that real files were produced (count > 0 or a non-empty list), at the
// top level or under metadata. This is the authoritative deliverable signal,
// preferred over title/summary/uri string heuristics.
func artifactHasFileChanges(artifact gin.H) bool {
if anyPositiveFileSignal(artifact) {
return true
}
if meta, ok := artifact["metadata"].(map[string]any); ok {
if anyPositiveFileSignal(meta) {
return true
}
}
return false
}
func anyPositiveFileSignal(values map[string]any) bool {
for _, key := range []string{"files_modified", "files_deleted", "files_changed", "files"} {
switch v := values[key].(type) {
case float64:
if v > 0 {
return true
}
case int:
if v > 0 {
return true
}
case []any:
if len(v) > 0 {
return true
}
case string:
if trimmed := strings.TrimSpace(v); trimmed != "" && trimmed != "0" {
return true
}
}
}
return false
}
// artifactIsSummaryOnly judges a single artifact by structured fields first
// (artifact_type + file-change signal), then falls back to the legacy title/
// summary/uri markers. Reading artifact_type fixes the case where Runtime emits
// artifact_type="document" (its no-files fallback) under a uri scheme that the
// old "/artifacts/summary" heuristic no longer matched.
func artifactIsSummaryOnly(artifact gin.H) bool {
// Runtime marks fallback artifacts (no real agent output) with
// metadata.synthesized=true — the authoritative non-deliverable signal
// (agent_management Sub Mode Runtime §7.2).
if meta, ok := artifact["metadata"].(map[string]any); ok {
if synth, ok := meta["synthesized"].(bool); ok && synth {
return true
}
}
atype := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["artifact_type"])))
if deliverableArtifactTypes[atype] || artifactHasFileChanges(artifact) {
return false
}
title := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["title"])))
summary := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["summary"])))
uri := strings.ToLower(strings.TrimSpace(fmt.Sprint(artifact["uri"])))
if strings.Contains(summary, "without per-agent artifacts") ||
strings.Contains(title, "runtime execution summary") ||
strings.Contains(title, "runtime execution failed") ||
strings.Contains(uri, "/artifacts/summary") {
return true
}
// Runtime emits artifact_type="document" (or "summary") for the no-deliverable
// fallback; with no file-change signal that is a summary, not a code delivery.
return atype == "document" || atype == "summary"
}
func runtimeArtifactsAreSummaryOnly(artifacts []gin.H) bool {
if len(artifacts) == 0 {
return false
}
for _, artifact := range artifacts {
if !artifactIsSummaryOnly(artifact) {
return false
}
}
return true
}
// persistedArtifactToGin adapts a stored artifact to the gin.H shape consumed
// by artifactIsSummaryOnly.
func persistedArtifactToGin(a model.AgentArtifact) gin.H {
g := gin.H{
"artifact_id": a.ArtifactID,
"artifact_type": a.ArtifactType,
"title": a.Title,
"summary": a.Summary,
"uri": a.URI,
}
if strings.TrimSpace(a.MetadataJSON) != "" {
var meta map[string]any
if err := common.UnmarshalJsonStr(a.MetadataJSON, &meta); err == nil && len(meta) > 0 {
g["metadata"] = meta
}
}
return g
}
// agentDeploymentDisplayStatus is the single status the client should show.
// Manager is the sole judge (unified spec §10.6): a `completed` runtime status
// is only surfaced as `completed` when there is a real (non-summary)
// deliverable; otherwise it is downgraded so an empty result is not shown as
// success — `needs_codegen` when only a plan/summary exists, or
// `completed_without_deliverable` when no artifact exists at all.
func agentDeploymentDisplayStatus(record agentDeploymentRecord) string {
if strings.ToLower(strings.TrimSpace(record.Status)) != "completed" {
return record.Status
}
artifacts, err := model.ListAgentArtifacts(model.ListAgentArtifactsFilter{
DeploymentID: record.DeploymentID,
Limit: 500,
})
if err != nil {
common.SysLog("agentDeploymentDisplayStatus: " + err.Error())
return record.Status
}
for _, a := range artifacts {
if !artifactIsSummaryOnly(persistedArtifactToGin(a)) {
return "completed"
}
}
if len(artifacts) > 0 {
return "needs_codegen"
}
return "completed_without_deliverable"
}
// heicodeClientMode maps the internal runtime mode to the client-facing mode
// string the desktop uses everywhere (capabilities/list/detail/workflow), so a
// task recovered from the list after restart routes to the right mode instead
// of defaulting to sub-agile (gap D/F). Internal `agent` => `sub_agile`.
func heicodeClientMode(record agentDeploymentRecord) string {
if normalizeAgentRuntimeMode(record.Plan.Metadata.RuntimeMode) == agentRuntimeModeSwarm {
return "swarm"
}
return "sub_agile"
}
// withDisplayStatus returns the record with DisplayStatus and the client-facing
// Mode computed for response (gap B/D/F).
func withDisplayStatus(record agentDeploymentRecord) agentDeploymentRecord {
record.DisplayStatus = agentDeploymentDisplayStatus(record)
record.Mode = heicodeClientMode(record)
return record
}
func ginString(g gin.H, key string) string {
v, ok := g[key]
if !ok || v == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(v))
}
// persistRuntimeStatusArtifact upserts an artifact surfaced by a runtime status
// query into the local store, so /artifacts and deliverable judgement converge
// even when the artifact.created callback was lost or raced.
func persistRuntimeStatusArtifact(record agentDeploymentRecord, a gin.H) {
artifactID := ginString(a, "artifact_id")
if artifactID == "" {
return
}
metaJSON := ""
if meta, ok := a["metadata"].(map[string]any); ok && len(meta) > 0 {
if raw, err := common.Marshal(meta); err == nil {
metaJSON = string(raw)
}
}
if err := model.UpsertAgentArtifact(&model.AgentArtifact{
ArtifactID: artifactID,
DeploymentID: record.DeploymentID,
UserID: record.Plan.UserContext.UserID,
BindingScope: firstPlanBindingScope(record.Plan),
ArtifactType: ginString(a, "artifact_type"),
Title: ginString(a, "title"),
Summary: ginString(a, "summary"),
URI: ginString(a, "uri"),
Checksum: ginString(a, "checksum"),
MetadataJSON: metaJSON,
CreatedAtMs: time.Now().UnixMilli(),
}); err != nil {
common.SysLog("persistRuntimeStatusArtifact: " + err.Error())
}
}
// reconcileDeploymentFromRuntime converges a non-terminal deployment from the
// authoritative runtime status. Manager must not rely solely on callbacks: they
// can race the runtime-id mapping (callback arrives before runtime_swarm_id is
// persisted) and orphan, leaving the deployment stuck at `accepted` while the
// runtime already completed. This pulls the live status on read and reflects
// terminal state + artifacts into the record.
func reconcileDeploymentFromRuntime(ctx context.Context, record agentDeploymentRecord) agentDeploymentRecord {
status := strings.ToLower(strings.TrimSpace(record.Status))
if containsString([]string{"completed", "failed", "stopped"}, status) {
return record
}
if strings.TrimSpace(record.RuntimeSwarmID) == "" && strings.TrimSpace(record.RuntimeDeploymentID) == "" {
return record
}
diag := agentRuntimeDiagnosticsForRecord(ctx, record)
if diag.DataSource != "runtime_status" || diag.HTTPStatus < 200 || diag.HTTPStatus >= 300 {
return record
}
rs := strings.ToLower(strings.TrimSpace(diag.Status))
if rs == "" {
return record
}
for _, a := range diag.Artifacts {
persistRuntimeStatusArtifact(record, a)
}
changed := false
if rs != strings.ToLower(strings.TrimSpace(record.Status)) {
record.Status = diag.Status
record.RuntimeState = diag.Status
changed = true
}
if strings.TrimSpace(diag.Phase) != "" && diag.Phase != record.Phase {
record.Phase = diag.Phase
changed = true
}
if containsString([]string{"completed", "failed", "stopped"}, rs) {
for idx := range record.AgentInstances {
cur := strings.ToLower(strings.TrimSpace(record.AgentInstances[idx].RuntimeState))
if !containsString([]string{"failed", "stopped"}, cur) {
record.AgentInstances[idx].RuntimeState = diag.Status
if record.Phase != "" {
record.AgentInstances[idx].Phase = record.Phase
}
}
}
changed = true
}
if changed {
record.UpdatedAt = agentNow()
agentMu.Lock()
agentDeployments[record.DeploymentID] = record
agentMu.Unlock()
if err := updateAgentDeploymentRecord(record); err != nil {
common.SysLog("reconcileDeploymentFromRuntime: " + err.Error())
}
}
return record
}
func buildAgentRuntimeDiagnostics(record agentDeploymentRecord, data map[string]any, httpStatus int, source string) agentRuntimeDiagnostics {
agents := mapSliceFromAny(data["agents"])
artifacts := mapSliceFromAny(data["artifacts"])
status := stringFromMap(data, "runtime_status", "status")
metrics := mapFromAny(data["metrics"])
warnings := make([]string, 0, 4)
agentFailed := runtimeAgentHasFailed(agents)
if agentFailed {
warnings = append(warnings, "runtime_agent_failed")
}
if strings.Contains(strings.ToLower(status), "completed") && agentFailed {
warnings = append(warnings, "runtime_completed_with_failed_agents")
}
if runtimeArtifactsAreSummaryOnly(artifacts) {
warnings = append(warnings, "runtime_summary_artifact_only")
}
if metrics != nil {
tokens := fmt.Sprint(metrics["tokens_used"])
if tokens == "0" || tokens == "0.0" {
warnings = append(warnings, "runtime_zero_model_usage")
}
}
return agentRuntimeDiagnostics{
DeploymentID: record.DeploymentID,
RuntimeMode: agentRuntimeModeForRecord(record),
SubMode: record.SubMode,
RuntimeDeploymentID: record.RuntimeDeploymentID,
RuntimeSwarmID: record.RuntimeSwarmID,
DataSource: source,
HTTPStatus: httpStatus,
Status: status,
Phase: stringFromMap(data, "phase", "stage"),
Progress: data["progress"],
ErrorMessage: stringFromMap(data, "error_message", "failure_reason", "error"),
Agents: agents,
Artifacts: artifacts,
Metrics: metrics,
Warnings: warnings,
CheckedAt: agentNow(),
}
}
func agentRuntimeDiagnosticsForRecord(ctx context.Context, record agentDeploymentRecord) agentRuntimeDiagnostics {
mode := agentRuntimeModeForRecord(record)
cfg := agentRuntimeClientConfigForMode(mode)
if !cfg.Enabled || strings.TrimSpace(cfg.BaseURL) == "" {
return agentRuntimeDiagnostics{
DeploymentID: record.DeploymentID,
RuntimeMode: mode,
SubMode: record.SubMode,
DataSource: "not_configured",
Warnings: []string{"runtime_not_configured"},
CheckedAt: agentNow(),
}
}
if strings.TrimSpace(record.RuntimeSwarmID) == "" && strings.TrimSpace(record.RuntimeDeploymentID) == "" {
return agentRuntimeDiagnostics{
DeploymentID: record.DeploymentID,
RuntimeMode: mode,
SubMode: record.SubMode,
DataSource: "missing_runtime_id",
Warnings: []string{"runtime_identifiers_missing"},
CheckedAt: agentNow(),
}
}
data, status, err := callAgentRuntimeStatus(ctx, cfg, record)
if err != nil {
return agentRuntimeDiagnostics{
DeploymentID: record.DeploymentID,
RuntimeMode: mode,
SubMode: record.SubMode,
RuntimeDeploymentID: record.RuntimeDeploymentID,
RuntimeSwarmID: record.RuntimeSwarmID,
DataSource: "runtime_status_error",
HTTPStatus: status,
ErrorMessage: truncateAgentFailureReason(err.Error()),
Warnings: []string{"runtime_status_query_failed"},
CheckedAt: agentNow(),
}
}
return buildAgentRuntimeDiagnostics(record, data, status, "runtime_status")
}
func AgentGetUserDeploymentRuntimeDiagnostics(c *gin.Context) {
record, ok := requireAuthenticatedUserAgentDeployment(c)
if !ok {
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), agentRuntimeClientConfigForMode(agentRuntimeModeForRecord(record)).Timeout)
defer cancel()
common.ApiSuccess(c, agentRuntimeDiagnosticsForRecord(ctx, record))
}
func agentRuntimeApprovalDecisionPath(cfg agentRuntimeConfig, record agentDeploymentRecord, approvalID string) string {
path := strings.TrimSpace(cfg.ApprovalDecisionPath)
if path == "" {
path = "/api/swarms/{swarm_id}/approvals/{approval_id}"
}
replacer := strings.NewReplacer(
"{swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{runtime_swarm_id}", url.PathEscape(strings.TrimSpace(record.RuntimeSwarmID)),
"{deployment_id}", url.PathEscape(strings.TrimSpace(record.RuntimeDeploymentID)),
"{runtime_deployment_id}", url.PathEscape(strings.TrimSpace(record.RuntimeDeploymentID)),
"{manager_deployment_id}", url.PathEscape(strings.TrimSpace(record.DeploymentID)),
"{approval_id}", url.PathEscape(strings.TrimSpace(approvalID)),
)
return replacer.Replace(path)
}
func callAgentRuntimeApprovalDecision(ctx context.Context, cfg agentRuntimeConfig, record agentDeploymentRecord, approval model.AgentApprovalRequest, lease *model.AgentCredentialLease, decision string) error {
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeApprovalDecisionPath(cfg, record, approval.ApprovalID))
if err != nil {
return err
}
payload := gin.H{
"approval_id": approval.ApprovalID,
"decision": decision,
"manager_deployment_id": record.DeploymentID,
"runtime_deployment_id": record.RuntimeDeploymentID,
"swarm_id": record.RuntimeSwarmID,
"operation": approval.Operation,
"resource_id": approval.ResourceID,
"resource_type": approval.ResourceType,
"resource_scope": approval.ResourceScope,
"target_role": approval.TargetRole,
"risk_level": approval.RiskLevel,
"requires_credential": approval.RequiresCredential,
"decided_by": approval.DecidedBy,
"reason": approval.DecisionReason,
"decided_at": approval.DecidedAt,
}
if approval.UserId > 0 {
payload["user_id"] = fmt.Sprintf("%d", approval.UserId)
}
if lease != nil && strings.TrimSpace(lease.CredentialRef) != "" {
payload["credential_ref"] = strings.TrimSpace(lease.CredentialRef)
payload["lease_id"] = strings.TrimSpace(lease.LeaseID)
payload["lease_expires_at"] = lease.ExpiresAt
}
body, err := common.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Correlation-ID", firstNonEmpty(record.Plan.Metadata.CorrelationID, approval.ApprovalID))
req.Header.Set("X-Idempotency-Key", "approval-decision-"+approval.ApprovalID+"-"+decision)
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 err
}
defer resp.Body.Close()
respBody, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return readErr
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("runtime approval decision returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
if len(respBody) == 0 {
return nil
}
var envelope map[string]any
if err := common.Unmarshal(respBody, &envelope); err != nil {
return err
}
if message := agentRuntimeEnvelopeError(envelope); message != "" {
return errors.New(message)
}
return nil
}
func syncAgentRuntimeApprovalDecision(c *gin.Context, approval *model.AgentApprovalRequest, lease *model.AgentCredentialLease, decision string) {
if approval == nil || strings.TrimSpace(approval.DeploymentID) == "" {
return
}
if !agentRuntimeClientConfigForMode(agentRuntimeModeAgent).Enabled && !agentRuntimeClientConfigForMode(agentRuntimeModeSwarm).Enabled {
return
}
record, ok := findAgentDeploymentRecord(approval.DeploymentID)
if !ok {
recordAgentApprovalAudit("runtime.approval_decision.skipped", approval, lease, "skipped", "deployment not found")
return
}
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeForRecord(record))
if !cfg.Enabled {
return
}
if strings.TrimSpace(record.RuntimeSwarmID) == "" && strings.TrimSpace(record.RuntimeDeploymentID) == "" {
recordAgentApprovalAudit("runtime.approval_decision.skipped", approval, lease, "skipped", "runtime identifiers missing")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), cfg.Timeout)
defer cancel()
if err := callAgentRuntimeApprovalDecision(ctx, cfg, record, *approval, lease, decision); err != nil {
recordAgentApprovalAudit("runtime.approval_decision.failed", approval, lease, "failed", truncateAgentFailureReason(err.Error()))
common.SysLog("Agent runtime approval decision failed for " + approval.ApprovalID + ": " + err.Error())
return
}
recordAgentApprovalAudit("runtime.approval_decision.accepted", approval, lease, "ok", "")
}
func callAgentRuntimeStop(ctx context.Context, cfg agentRuntimeConfig, record agentDeploymentRecord, reason string) (agentRuntimeSyncResult, error) {
runtimeDeploymentID := strings.TrimSpace(record.RuntimeDeploymentID)
if runtimeDeploymentID == "" {
return agentRuntimeSyncResult{}, nil
}
endpoint, err := agentRuntimeURL(cfg.BaseURL, agentRuntimeStopPathForRecord(cfg, record))
if err != nil {
return agentRuntimeSyncResult{}, err
}
payload, err := common.Marshal(gin.H{
"reason": firstNonEmpty(strings.TrimSpace(reason), "Heicode Manager requested stop"),
"manager_deployment_id": record.DeploymentID,
})
if err != nil {
return agentRuntimeSyncResult{}, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return agentRuntimeSyncResult{}, err
}
agentRuntimeHeaders(req, cfg, record)
client := &http.Client{Timeout: cfg.Timeout}
resp, err := client.Do(req)
if err != nil {
return agentRuntimeSyncResult{}, err
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if readErr != nil {
return agentRuntimeSyncResult{}, readErr
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return agentRuntimeSyncResult{RawStatusCode: resp.StatusCode}, fmt.Errorf("runtime stop returned HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var envelope map[string]any
if len(body) > 0 {
if err := common.Unmarshal(body, &envelope); err != nil {
return agentRuntimeSyncResult{RawStatusCode: resp.StatusCode}, err
}
}
if message := agentRuntimeEnvelopeError(envelope); message != "" {
return agentRuntimeSyncResult{RawStatusCode: resp.StatusCode}, errors.New(message)
}
data := extractAgentRuntimeData(envelope)
return agentRuntimeSyncResult{
RuntimeDeploymentID: firstNonEmpty(stringFromMap(data, "runtime_deployment_id", "deployment_id", "id"), runtimeDeploymentID),
RuntimeSwarmID: stringFromMap(data, "swarm_id", "runtime_swarm_id"),
RuntimeStatus: stringFromMap(data, "runtime_status", "status"),
RawStatusCode: resp.StatusCode,
}, nil
}
func syncAgentRuntimeStop(c *gin.Context, record agentDeploymentRecord, reason string) (agentDeploymentRecord, bool) {
cfg := agentRuntimeClientConfigForMode(agentRuntimeModeForRecord(record))
if !cfg.Enabled || strings.TrimSpace(record.RuntimeDeploymentID) == "" {
return record, true
}
ctx, cancel := context.WithTimeout(c.Request.Context(), cfg.Timeout)
defer cancel()
result, err := callAgentRuntimeStop(ctx, cfg, record, reason)
record.RuntimeLastSyncAt = agentNow()
if err != nil {
record.RuntimeState = agentRuntimeStateFailed
record.FailureReason = truncateAgentFailureReason(err.Error())
record.UpdatedAt = agentNow()
_ = updateAgentDeploymentRecord(record)
agentMu.Lock()
agentDeployments[record.DeploymentID] = record
agentMu.Unlock()
recordAgentRuntimeSyncAudit(record, "runtime.stop.failed", "failed")
agentError(c, "RUNTIME_STOP_FAILED", record.FailureReason)
return record, false
}
record.RuntimeState = firstNonEmpty(result.RuntimeStatus, "stopped")
if result.RuntimeDeploymentID != "" {
record.RuntimeDeploymentID = result.RuntimeDeploymentID
}
if result.RuntimeSwarmID != "" {
record.RuntimeSwarmID = result.RuntimeSwarmID
}
record.FailureReason = ""
recordAgentRuntimeSyncAudit(record, "runtime.stop.accepted", "ok")
return record, true
}
func updateAgentRuntimeSyncState(record agentDeploymentRecord, result agentRuntimeSyncResult, syncErr error) agentDeploymentRecord {
record.RuntimeLastSyncAt = agentNow()
if syncErr != nil {
record.RuntimeState = agentRuntimeStateFailed
record.FailureReason = truncateAgentFailureReason(syncErr.Error())
} else {
record.RuntimeState = firstNonEmpty(result.RuntimeStatus, agentRuntimeStateSynced)
record.RuntimeDeploymentID = result.RuntimeDeploymentID
record.RuntimeSwarmID = result.RuntimeSwarmID
record.FailureReason = ""
}
record.UpdatedAt = agentNow()
if err := updateAgentDeploymentRecord(record); err != nil {
common.SysLog("updateAgentRuntimeSyncState: " + err.Error())
}
agentMu.Lock()
agentDeployments[record.DeploymentID] = record
agentMu.Unlock()
return record
}
func recordAgentRuntimeSyncAudit(record agentDeploymentRecord, event string, result string) {
recordAgentAuditEvent(agentEvent{
EventID: "evt_" + common.GetUUID()[:12],
Event: event,
SchemaVersion: 1,
UserID: record.Plan.UserContext.UserID,
ChannelID: record.Plan.UserContext.ChannelID,
BindingScope: firstPlanBindingScope(record.Plan),
DeploymentID: record.DeploymentID,
CorrelationID: record.Plan.Metadata.CorrelationID,
OccurredAt: agentNow(),
}, "agent_runtime_bridge", record.DeploymentID, "", result)
}
func dispatchAgentRuntimeCreate(record agentDeploymentRecord, source string, cfg agentRuntimeConfig) agentDeploymentRecord {
recordAgentRuntimeSyncAudit(record, "runtime.sync.started", "started")
ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout)
defer cancel()
result, err := callAgentRuntimeCreate(ctx, cfg, record, source)
record = updateAgentRuntimeSyncState(record, result, err)
if err != nil {
common.SysLog("Agent runtime shadow create failed for " + record.DeploymentID + ": " + err.Error())
recordAgentRuntimeSyncAudit(record, "runtime.sync.failed", "failed")
return record
}
recordAgentRuntimeSyncAudit(record, "runtime.sync.accepted", "ok")
return record
}
func maybeDispatchAgentRuntimeCreate(c *gin.Context, record agentDeploymentRecord, source string) agentDeploymentRecord {
mode := agentRuntimeModeForSource(source)
if strings.TrimSpace(record.Plan.Metadata.RuntimeMode) == "" {
record.Plan.Metadata.RuntimeMode = mode
} else {
mode = agentRuntimeModeForRecord(record)
}
cfg := agentRuntimeClientConfigForMode(mode)
if !cfg.Enabled {
return record
}
if _, err := agentRuntimeURL(cfg.BaseURL, cfg.CreatePath); err != nil {
record.RuntimeState = agentRuntimeStateFailed
record.RuntimeLastSyncAt = agentNow()
record.FailureReason = truncateAgentFailureReason(err.Error())
record.UpdatedAt = agentNow()
_ = updateAgentDeploymentRecord(record)
recordAgentRuntimeSyncAudit(record, "runtime.sync.failed", "failed")
return record
}
record.RuntimeState = agentRuntimeStateSyncing
record.RuntimeLastSyncAt = agentNow()
record.UpdatedAt = agentNow()
if err := updateAgentDeploymentRecord(record); err != nil {
common.SysLog("maybeDispatchAgentRuntimeCreate: " + err.Error())
}
agentMu.Lock()
agentDeployments[record.DeploymentID] = record
agentMu.Unlock()
if cfg.Async {
sourceCopy := source
recordCopy := record
go dispatchAgentRuntimeCreate(recordCopy, sourceCopy, cfg)
return record
}
return dispatchAgentRuntimeCreate(record, source, cfg)
}
func AgentRuntimeHealth(c *gin.Context) {
cfg := agentRuntimeClientConfigForMode(c.Query("mode"))
data := gin.H{
"enabled": cfg.Enabled,
"configured": cfg.BaseURL != "",
"create_path": cfg.CreatePath,
"health_path": cfg.HealthPath,
"stop_path": cfg.StopPath,
"mode": normalizeAgentRuntimeMode(c.Query("mode")),
}
if cfg.BaseURL == "" {
data["status"] = "not_configured"
common.ApiSuccess(c, data)
return
}
endpoint, err := agentRuntimeURL(cfg.BaseURL, cfg.HealthPath)
if err != nil {
data["status"] = "invalid_config"
data["message"] = err.Error()
common.ApiSuccess(c, data)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), cfg.Timeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
data["status"] = "request_failed"
data["message"] = err.Error()
common.ApiSuccess(c, data)
return
}
if cfg.Token != "" {
req.Header.Set("Authorization", "Bearer "+cfg.Token)
}
resp, err := (&http.Client{Timeout: cfg.Timeout}).Do(req)
if err != nil {
data["status"] = "unreachable"
data["message"] = err.Error()
common.ApiSuccess(c, data)
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
data["http_status"] = resp.StatusCode
if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices {
data["status"] = "healthy"
} else {
data["status"] = "unhealthy"
}
if len(body) > 0 {
var remote map[string]any
if err := common.Unmarshal(body, &remote); err == nil {
data["remote"] = remote
} else {
data["body"] = strings.TrimSpace(string(body))
}
}
common.ApiSuccess(c, data)
}