package services import ( "crypto/rand" "encoding/hex" "encoding/json" "fmt" "strings" "time" "clawreef/internal/models" "clawreef/internal/repository" ) const ( AgentProtocolVersionV1 = "v1" agentStatusOnline = "online" agentStatusOffline = "offline" agentStatusStale = "stale" openClawStatusUnknown = "unknown" ) type AgentRegisterRequest struct { InstanceID int `json:"instance_id" binding:"required,min=1"` AgentID string `json:"agent_id" binding:"required"` AgentVersion string `json:"agent_version" binding:"required"` ProtocolVersion string `json:"protocol_version" binding:"required"` Capabilities []string `json:"capabilities"` HostInfo map[string]interface{} `json:"host_info"` } type AgentRegisterResponse struct { SessionToken string `json:"session_token"` SessionExpiresAt time.Time `json:"session_expires_at"` HeartbeatIntervalSeconds int `json:"heartbeat_interval_seconds"` CommandPollIntervalSeconds int `json:"command_poll_interval_seconds"` ServerTime time.Time `json:"server_time"` } type AgentHeartbeatRequest struct { AgentID string `json:"agent_id" binding:"required"` Timestamp time.Time `json:"timestamp"` OpenClawStatus string `json:"openclaw_status"` CurrentConfigRevisionID *int `json:"current_config_revision_id,omitempty"` Summary map[string]interface{} `json:"summary"` } type AgentHeartbeatResponse struct { ServerTime time.Time `json:"server_time"` HasPendingCommand bool `json:"has_pending_command"` DesiredPowerState string `json:"desired_power_state"` DesiredConfigRevisionID *int `json:"desired_config_revision_id,omitempty"` } type InstanceAgentPayload struct { AgentID string `json:"agent_id"` AgentVersion string `json:"agent_version"` ProtocolVersion string `json:"protocol_version"` Status string `json:"status"` Capabilities []string `json:"capabilities"` HostInfo map[string]interface{} `json:"host_info,omitempty"` LastHeartbeatAt *time.Time `json:"last_heartbeat_at,omitempty"` LastReportedAt *time.Time `json:"last_reported_at,omitempty"` LastSeenIP *string `json:"last_seen_ip,omitempty"` RegisteredAt *time.Time `json:"registered_at,omitempty"` } type AgentSession struct { Instance *models.Instance Agent *models.InstanceAgent } type InstanceAgentService interface { Register(bootstrapToken string, req AgentRegisterRequest, clientIP string) (*AgentRegisterResponse, error) AuthenticateSession(sessionToken string) (*AgentSession, error) Heartbeat(session *AgentSession, req AgentHeartbeatRequest, clientIP string) (*AgentHeartbeatResponse, error) GetPayloadByInstanceID(instanceID int) (*InstanceAgentPayload, error) } type instanceAgentService struct { instanceRepo repository.InstanceRepository agentRepo repository.InstanceAgentRepository desiredStateRepo repository.InstanceDesiredStateRepository runtimeRepo repository.InstanceRuntimeStatusRepository commandRepo repository.InstanceCommandRepository } func NewInstanceAgentService(instanceRepo repository.InstanceRepository, agentRepo repository.InstanceAgentRepository, desiredStateRepo repository.InstanceDesiredStateRepository, runtimeRepo repository.InstanceRuntimeStatusRepository, commandRepo repository.InstanceCommandRepository) InstanceAgentService { return &instanceAgentService{ instanceRepo: instanceRepo, agentRepo: agentRepo, desiredStateRepo: desiredStateRepo, runtimeRepo: runtimeRepo, commandRepo: commandRepo, } } func (s *instanceAgentService) Register(bootstrapToken string, req AgentRegisterRequest, clientIP string) (*AgentRegisterResponse, error) { bootstrapToken = strings.TrimSpace(bootstrapToken) if bootstrapToken == "" { return nil, fmt.Errorf("agent bootstrap token is required") } if strings.TrimSpace(req.AgentID) == "" { return nil, fmt.Errorf("agent id is required") } if strings.TrimSpace(req.ProtocolVersion) != AgentProtocolVersionV1 { return nil, fmt.Errorf("unsupported agent protocol version") } instance, err := s.instanceRepo.GetByAgentBootstrapToken(bootstrapToken) if err != nil { return nil, err } if instance == nil || instance.ID != req.InstanceID { return nil, fmt.Errorf("invalid agent bootstrap token") } if !supportsManagedRuntimeIntegration(instance.Type) { return nil, fmt.Errorf("agent registration is only supported for openclaw or hermes instances") } now := time.Now().UTC() sessionToken, err := generatePrefixedToken("agt_sess") if err != nil { return nil, fmt.Errorf("failed to generate agent session token: %w", err) } sessionExpiresAt := now.Add(24 * time.Hour) capabilitiesJSON, err := marshalJSON(req.Capabilities) if err != nil { return nil, fmt.Errorf("failed to encode agent capabilities: %w", err) } hostInfoJSON, err := marshalOptionalJSON(req.HostInfo) if err != nil { return nil, fmt.Errorf("failed to encode agent host info: %w", err) } agent, err := s.agentRepo.GetByInstanceID(instance.ID) if err != nil { return nil, err } if agent == nil { agent = &models.InstanceAgent{ InstanceID: instance.ID, AgentID: strings.TrimSpace(req.AgentID), AgentVersion: strings.TrimSpace(req.AgentVersion), ProtocolVersion: strings.TrimSpace(req.ProtocolVersion), Status: agentStatusOnline, CapabilitiesJSON: capabilitiesJSON, HostInfoJSON: hostInfoJSON, SessionToken: &sessionToken, SessionExpiresAt: &sessionExpiresAt, LastHeartbeatAt: &now, LastReportedAt: nil, LastSeenIP: optionalString(strings.TrimSpace(clientIP)), RegisteredAt: &now, } if err := s.agentRepo.Create(agent); err != nil { return nil, err } } else { agent.AgentID = strings.TrimSpace(req.AgentID) agent.AgentVersion = strings.TrimSpace(req.AgentVersion) agent.ProtocolVersion = strings.TrimSpace(req.ProtocolVersion) agent.Status = agentStatusOnline agent.CapabilitiesJSON = capabilitiesJSON agent.HostInfoJSON = hostInfoJSON agent.SessionToken = &sessionToken agent.SessionExpiresAt = &sessionExpiresAt agent.LastHeartbeatAt = &now agent.LastSeenIP = optionalString(strings.TrimSpace(clientIP)) agent.RegisteredAt = &now if err := s.agentRepo.Update(agent); err != nil { return nil, err } } if _, err := s.ensureDesiredState(instance); err != nil { return nil, err } if _, err := s.ensureRuntimeStatus(instance.ID); err != nil { return nil, err } return &AgentRegisterResponse{ SessionToken: sessionToken, SessionExpiresAt: sessionExpiresAt, HeartbeatIntervalSeconds: 15, CommandPollIntervalSeconds: 5, ServerTime: now, }, nil } func (s *instanceAgentService) AuthenticateSession(sessionToken string) (*AgentSession, error) { sessionToken = strings.TrimSpace(sessionToken) if sessionToken == "" { return nil, fmt.Errorf("agent session token is required") } agent, err := s.agentRepo.GetBySessionToken(sessionToken) if err != nil { return nil, err } if agent == nil || agent.SessionExpiresAt == nil || agent.SessionExpiresAt.Before(time.Now().UTC()) { return nil, fmt.Errorf("invalid or expired agent session token") } instance, err := s.instanceRepo.GetByID(agent.InstanceID) if err != nil { return nil, err } if instance == nil { return nil, fmt.Errorf("instance not found") } return &AgentSession{Instance: instance, Agent: agent}, nil } func (s *instanceAgentService) Heartbeat(session *AgentSession, req AgentHeartbeatRequest, clientIP string) (*AgentHeartbeatResponse, error) { if session == nil || session.Agent == nil || session.Instance == nil { return nil, fmt.Errorf("agent session is required") } if strings.TrimSpace(req.AgentID) == "" || strings.TrimSpace(req.AgentID) != session.Agent.AgentID { return nil, fmt.Errorf("agent id does not match session") } now := time.Now().UTC() session.Agent.Status = agentStatusOnline session.Agent.LastHeartbeatAt = &now session.Agent.LastSeenIP = optionalString(strings.TrimSpace(clientIP)) // Renew session expiry on each successful heartbeat (rolling window). // Without this, the 24-hour session token issued at Register() silently // expires and the agent is permanently locked out until pod restart. newExpiry := now.Add(24 * time.Hour) session.Agent.SessionExpiresAt = &newExpiry if err := s.agentRepo.Update(session.Agent); err != nil { return nil, err } runtimeStatus, err := s.ensureRuntimeStatus(session.Instance.ID) if err != nil { return nil, err } runtimeStatus.AgentStatus = agentStatusOnline if strings.TrimSpace(req.OpenClawStatus) != "" { runtimeStatus.OpenClawStatus = strings.TrimSpace(req.OpenClawStatus) } runtimeStatus.CurrentConfigRevisionID = req.CurrentConfigRevisionID if req.Summary != nil { summaryJSON, err := marshalOptionalJSON(req.Summary) if err != nil { return nil, fmt.Errorf("failed to encode heartbeat summary: %w", err) } runtimeStatus.SummaryJSON = summaryJSON if openClawPID, ok := req.Summary["openclaw_pid"].(float64); ok { pid := int(openClawPID) runtimeStatus.OpenClawPID = &pid } } runtimeStatus.LastReportedAt = &now if err := s.runtimeRepo.Update(runtimeStatus); err != nil { return nil, err } desiredState, err := s.ensureDesiredState(session.Instance) if err != nil { return nil, err } nextCommand, err := s.commandRepo.GetNextPendingByInstance(session.Instance.ID) if err != nil { return nil, err } return &AgentHeartbeatResponse{ ServerTime: now, HasPendingCommand: nextCommand != nil, DesiredPowerState: desiredState.DesiredPowerState, DesiredConfigRevisionID: desiredState.DesiredConfigRevisionID, }, nil } func (s *instanceAgentService) GetPayloadByInstanceID(instanceID int) (*InstanceAgentPayload, error) { agent, err := s.agentRepo.GetByInstanceID(instanceID) if err != nil { return nil, err } if agent == nil { return nil, nil } payload := &InstanceAgentPayload{ AgentID: agent.AgentID, AgentVersion: agent.AgentVersion, ProtocolVersion: agent.ProtocolVersion, Status: deriveAgentStatus(agent), LastHeartbeatAt: agent.LastHeartbeatAt, LastReportedAt: agent.LastReportedAt, LastSeenIP: agent.LastSeenIP, RegisteredAt: agent.RegisteredAt, } if err := unmarshalJSON(agent.CapabilitiesJSON, &payload.Capabilities); err != nil { return nil, fmt.Errorf("failed to decode agent capabilities: %w", err) } if agent.HostInfoJSON != nil && strings.TrimSpace(*agent.HostInfoJSON) != "" { if err := unmarshalJSON(*agent.HostInfoJSON, &payload.HostInfo); err != nil { return nil, fmt.Errorf("failed to decode agent host info: %w", err) } } return payload, nil } func (s *instanceAgentService) ensureDesiredState(instance *models.Instance) (*models.InstanceDesiredState, error) { state, err := s.desiredStateRepo.GetByInstanceID(instance.ID) if err != nil { return nil, err } if state != nil { return state, nil } desiredPowerState := "stopped" if instance.Status == "running" || instance.Status == "creating" { desiredPowerState = "running" } now := time.Now().UTC() state = &models.InstanceDesiredState{ InstanceID: instance.ID, DesiredPowerState: desiredPowerState, UpdatedAt: now, CreatedAt: now, } if err := s.desiredStateRepo.Create(state); err != nil { return nil, err } return state, nil } func (s *instanceAgentService) ensureRuntimeStatus(instanceID int) (*models.InstanceRuntimeStatus, error) { status, err := s.runtimeRepo.GetByInstanceID(instanceID) if err != nil { return nil, err } if status != nil { return status, nil } now := time.Now().UTC() status = &models.InstanceRuntimeStatus{ InstanceID: instanceID, InfraStatus: "creating", AgentStatus: agentStatusOffline, OpenClawStatus: openClawStatusUnknown, CreatedAt: now, UpdatedAt: now, } if err := s.runtimeRepo.Create(status); err != nil { return nil, err } return status, nil } func deriveAgentStatus(agent *models.InstanceAgent) string { if agent == nil || agent.LastHeartbeatAt == nil { return agentStatusOffline } age := time.Since(*agent.LastHeartbeatAt) switch { case age <= 45*time.Second: return agentStatusOnline case age <= 120*time.Second: return agentStatusStale default: return agentStatusOffline } } func generatePrefixedToken(prefix string) (string, error) { bytes := make([]byte, 24) if _, err := rand.Read(bytes); err != nil { return "", err } return prefix + "_" + hex.EncodeToString(bytes), nil } func marshalJSON(value interface{}) (string, error) { raw, err := json.Marshal(value) if err != nil { return "", err } return string(raw), nil } func marshalOptionalJSON(value interface{}) (*string, error) { if value == nil { return nil, nil } raw, err := json.Marshal(value) if err != nil { return nil, err } encoded := string(raw) return &encoded, nil } func unmarshalJSON(raw string, target interface{}) error { if strings.TrimSpace(raw) == "" { return nil } return json.Unmarshal([]byte(raw), target) } func optionalString(value string) *string { if strings.TrimSpace(value) == "" { return nil } trimmed := strings.TrimSpace(value) return &trimmed }