按桌面客户端统一方案 v0.1 + agent_management Sub Mode Runtime 对接,强制全量统一,不留兼容。
命名统一(强制,无兼容):
- 全仓 agnet/Agnet/AGNET → agent/Agent/AGENT:后端 Go(路由 /api/agent/*、env AGENT_*、
结构体/函数、19 个文件改名)、前端(agent-console/agent-hub、/api/agent 调用、i18n)、
DB(表 agent_*、列 agent_id)、compose/.env、文档、脚本。
- DB 加幂等迁移 renameAgnetTablesToAgent():启动时 rename 老 agnet_* 表/列,保住生产数据。
统一方案核心(10 项):
- callback 统一 /api/agent/callbacks/runtime-events(路由/广播URL/函数名)。
- artifact 兜底判定改用 Runtime 权威信号 metadata.synthesized(§7.2)+ 结构化 artifact_type。
- Manager→Runtime 路径对齐 /api/agent/sub-agile/deployments(§2.2),{deployment_id} 回退 swarm_id。
- 状态裁决 display_status:Manager 唯一裁判,completed 无有效产物→needs_codegen/
completed_without_deliverable(§10.6),接入 detail/timeline/workflow。
- GET /api/heicode/capabilities 能力发现(§6)。
- 模型策略 per_role(role_models)+ 收集 allowed_model_ids(§9)。
- resource_binding_id→secret_ref 服务端解析,客户端不再 inline secret_ref(§17.6)。
- 客户端统一路由层 /api/heicode/sub-agile|swarm/*(task≡deployment,复用控制面)+ workflow 投影。
- 日志分层 user_logs/debug_logs(§13)。
验证:go build ./... + go test(controller/router/model/middleware)全绿;前端 tsc -b + rsbuild build 通过。
待部署:VM .env 的 AGNET_*→AGENT_*;启动迁移自动 rename 表;其他三仓库需同步切到 /api/agent。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1258 lines
44 KiB
Go
1258 lines
44 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}"
|
|
if mode == agentRuntimeModeSwarm {
|
|
prefix = "SWARM_RUNTIME_"
|
|
defaultCreatePath = "/api/swarms"
|
|
defaultStopPath = "/api/swarms/{swarm_id}/stop"
|
|
defaultApprovalPath = "/api/swarms/{swarm_id}/approvals/{approval_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", "/api/swarms/{swarm_id}/status"),
|
|
ArtifactContentPath: common.GetEnvOrDefaultString(prefix+"ARTIFACT_CONTENT_PATH", "/api/swarms/{swarm_id}/artifacts/{artifact_id}/content"),
|
|
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))
|
|
}
|
|
|
|
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"
|
|
}
|
|
|
|
// withDisplayStatus returns the record with DisplayStatus computed for response.
|
|
func withDisplayStatus(record agentDeploymentRecord) agentDeploymentRecord {
|
|
record.DisplayStatus = agentDeploymentDisplayStatus(record)
|
|
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)
|
|
}
|