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:
hsiegeln
2026-03-11 18:40:57 +01:00
parent 61f39021b3
commit 0372be2334
8 changed files with 452 additions and 1 deletions

View File

@@ -0,0 +1,155 @@
package com.cameleer3.server.app.controller;
import com.cameleer3.server.app.AbstractClickHouseIT;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
class AgentRegistrationControllerIT extends AbstractClickHouseIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
private HttpHeaders protocolHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
}
private HttpHeaders protocolHeadersNoBody() {
HttpHeaders headers = new HttpHeaders();
headers.set("X-Cameleer-Protocol-Version", "1");
return headers;
}
private ResponseEntity<String> registerAgent(String agentId, String name) {
String json = """
{
"agentId": "%s",
"name": "%s",
"group": "test-group",
"version": "1.0.0",
"routeIds": ["route-1", "route-2"],
"capabilities": {"tracing": true}
}
""".formatted(agentId, name);
return restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, protocolHeaders()),
String.class);
}
@Test
void registerNewAgent_returns200WithAgentIdAndSseEndpoint() throws Exception {
ResponseEntity<String> response = registerAgent("agent-it-1", "IT Agent 1");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("agentId").asText()).isEqualTo("agent-it-1");
assertThat(body.get("sseEndpoint").asText()).isEqualTo("/api/v1/agents/agent-it-1/events");
assertThat(body.get("heartbeatIntervalMs").asLong()).isGreaterThan(0);
assertThat(body.has("serverPublicKey")).isTrue();
}
@Test
void reRegisterSameAgent_returns200WithLiveState() throws Exception {
registerAgent("agent-it-reregister", "First Registration");
ResponseEntity<String> response = registerAgent("agent-it-reregister", "Second Registration");
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.get("agentId").asText()).isEqualTo("agent-it-reregister");
}
@Test
void heartbeatKnownAgent_returns200() {
registerAgent("agent-it-hb", "HB Agent");
ResponseEntity<Void> response = restTemplate.exchange(
"/api/v1/agents/agent-it-hb/heartbeat",
HttpMethod.POST,
new HttpEntity<>(protocolHeadersNoBody()),
Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void heartbeatUnknownAgent_returns404() {
ResponseEntity<Void> response = restTemplate.exchange(
"/api/v1/agents/unknown-agent-xyz/heartbeat",
HttpMethod.POST,
new HttpEntity<>(protocolHeadersNoBody()),
Void.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
void listAllAgents_returnsBothAgents() throws Exception {
registerAgent("agent-it-list-1", "List Agent 1");
registerAgent("agent-it-list-2", "List Agent 2");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents",
HttpMethod.GET,
new HttpEntity<>(protocolHeadersNoBody()),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
// At minimum, our two agents should be present (may have more from other tests)
assertThat(body.size()).isGreaterThanOrEqualTo(2);
}
@Test
void listAgentsByStatus_filtersCorrectly() throws Exception {
registerAgent("agent-it-filter", "Filter Agent");
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents?status=LIVE",
HttpMethod.GET,
new HttpEntity<>(protocolHeadersNoBody()),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
JsonNode body = objectMapper.readTree(response.getBody());
assertThat(body.isArray()).isTrue();
// All returned agents should be LIVE
for (JsonNode agent : body) {
assertThat(agent.get("state").asText()).isEqualTo("LIVE");
}
}
@Test
void listAgentsWithInvalidStatus_returns400() {
ResponseEntity<String> response = restTemplate.exchange(
"/api/v1/agents?status=INVALID",
HttpMethod.GET,
new HttpEntity<>(protocolHeadersNoBody()),
String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST);
}
}