feat(03-01): add agent registration controller, config, lifecycle monitor
- AgentRegistryConfig: heartbeat, stale, dead, ping, command expiry settings
- AgentRegistryBeanConfig: wires AgentRegistryService as Spring bean
- AgentLifecycleMonitor: @Scheduled lifecycle check + command expiry sweep
- AgentRegistrationController: POST /register, POST /{id}/heartbeat, GET /agents
- Updated Cameleer3ServerApplication with AgentRegistryConfig
- Updated application.yml with agent-registry section and async timeout
- 7 integration tests: register, re-register, heartbeat, list, filter, invalid status
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
package com.cameleer3.server.app.controller;
|
||||
|
||||
import com.cameleer3.server.app.config.AgentRegistryConfig;
|
||||
import com.cameleer3.server.core.agent.AgentInfo;
|
||||
import com.cameleer3.server.core.agent.AgentRegistryService;
|
||||
import com.cameleer3.server.core.agent.AgentState;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Agent registration, heartbeat, and listing endpoints.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/agents")
|
||||
@Tag(name = "Agent Management", description = "Agent registration and lifecycle endpoints")
|
||||
public class AgentRegistrationController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(AgentRegistrationController.class);
|
||||
|
||||
private final AgentRegistryService registryService;
|
||||
private final AgentRegistryConfig config;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public AgentRegistrationController(AgentRegistryService registryService,
|
||||
AgentRegistryConfig config,
|
||||
ObjectMapper objectMapper) {
|
||||
this.registryService = registryService;
|
||||
this.config = config;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
@Operation(summary = "Register an agent",
|
||||
description = "Registers a new agent or re-registers an existing one")
|
||||
@ApiResponse(responseCode = "200", description = "Agent registered successfully")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid registration payload")
|
||||
public ResponseEntity<String> register(@RequestBody String body) throws JsonProcessingException {
|
||||
JsonNode node = objectMapper.readTree(body);
|
||||
|
||||
String agentId = getRequiredField(node, "agentId");
|
||||
String name = getRequiredField(node, "name");
|
||||
if (agentId == null || name == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body("{\"error\":\"agentId and name are required\"}");
|
||||
}
|
||||
|
||||
String group = node.has("group") ? node.get("group").asText() : "default";
|
||||
String version = node.has("version") ? node.get("version").asText() : null;
|
||||
|
||||
List<String> routeIds = new ArrayList<>();
|
||||
if (node.has("routeIds") && node.get("routeIds").isArray()) {
|
||||
for (JsonNode rid : node.get("routeIds")) {
|
||||
routeIds.add(rid.asText());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object> capabilities = Collections.emptyMap();
|
||||
if (node.has("capabilities") && node.get("capabilities").isObject()) {
|
||||
capabilities = new LinkedHashMap<>();
|
||||
Iterator<Map.Entry<String, JsonNode>> fields = node.get("capabilities").fields();
|
||||
while (fields.hasNext()) {
|
||||
Map.Entry<String, JsonNode> field = fields.next();
|
||||
capabilities.put(field.getKey(), parseJsonValue(field.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
AgentInfo agent = registryService.register(agentId, name, group, version, routeIds, capabilities);
|
||||
log.info("Agent registered: {} (name={}, group={})", agentId, name, group);
|
||||
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("agentId", agent.id());
|
||||
response.put("sseEndpoint", "/api/v1/agents/" + agentId + "/events");
|
||||
response.put("heartbeatIntervalMs", config.getHeartbeatIntervalMs());
|
||||
response.put("serverPublicKey", null);
|
||||
|
||||
return ResponseEntity.ok(objectMapper.writeValueAsString(response));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/heartbeat")
|
||||
@Operation(summary = "Agent heartbeat ping",
|
||||
description = "Updates the agent's last heartbeat timestamp")
|
||||
@ApiResponse(responseCode = "200", description = "Heartbeat accepted")
|
||||
@ApiResponse(responseCode = "404", description = "Agent not registered")
|
||||
public ResponseEntity<Void> heartbeat(@PathVariable String id) {
|
||||
boolean found = registryService.heartbeat(id);
|
||||
if (!found) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
@Operation(summary = "List all agents",
|
||||
description = "Returns all registered agents, optionally filtered by status")
|
||||
@ApiResponse(responseCode = "200", description = "Agent list returned")
|
||||
@ApiResponse(responseCode = "400", description = "Invalid status filter")
|
||||
public ResponseEntity<String> listAgents(
|
||||
@RequestParam(required = false) String status) throws JsonProcessingException {
|
||||
List<AgentInfo> agents;
|
||||
|
||||
if (status != null) {
|
||||
try {
|
||||
AgentState stateFilter = AgentState.valueOf(status.toUpperCase());
|
||||
agents = registryService.findByState(stateFilter);
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body("{\"error\":\"Invalid status: " + status + ". Valid values: LIVE, STALE, DEAD\"}");
|
||||
}
|
||||
} else {
|
||||
agents = registryService.findAll();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(objectMapper.writeValueAsString(agents));
|
||||
}
|
||||
|
||||
private String getRequiredField(JsonNode node, String fieldName) {
|
||||
if (!node.has(fieldName) || node.get(fieldName).isNull() || node.get(fieldName).asText().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return node.get(fieldName).asText();
|
||||
}
|
||||
|
||||
private Object parseJsonValue(JsonNode node) {
|
||||
if (node.isBoolean()) return node.asBoolean();
|
||||
if (node.isInt()) return node.asInt();
|
||||
if (node.isLong()) return node.asLong();
|
||||
if (node.isDouble()) return node.asDouble();
|
||||
if (node.isTextual()) return node.asText();
|
||||
return node.toString();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user