feat(license): enforce max_agents at AgentRegistryService.register

Adds a CreateGuard to AgentRegistryService that fires only on NEW
registrations: re-registers of an existing agent bypass the cap (they
don't grow the registry, and rejecting them would orphan an agent that
already counts against the cap). Live-only count for cap enforcement —
STALE/DEAD/SHUTDOWN agents are excluded so the cap reflects the working
fleet, not historical residue.

Reuses the CreateGuard pattern from T18-T19. The global
LicenseExceptionAdvice maps the resulting LicenseCapExceededException to
403 with the structured envelope — no AgentRegistrationController
changes needed.

AgentCapEnforcementIT exercises the HTTP path end-to-end: two registers
succeed at cap=2, a third returns 403 with the expected envelope, and a
re-register of an already-registered agent succeeds at-cap.

Sibling agent-registering ITs (Agent*ControllerIT, Diagram*IT,
Execution*IT, Search*IT, Protocol*IT, Backpressure*IT, JwtRefresh*IT,
Registration*IT, Security*IT, SseSigning*IT, IngestionSchemaIT) lift
max_agents in @BeforeEach and clear the synthetic license in @AfterEach
— the in-memory registry is shared across @SpringBootTest reuse
boundaries, so without the lift the default-tier max_agents=5 would be
exhausted by accumulated test residue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 14:19:08 +02:00
parent 80dafe685b
commit afdaee628b
21 changed files with 367 additions and 3 deletions

View File

@@ -17,11 +17,13 @@ import org.springframework.context.annotation.Configuration;
public class AgentRegistryBeanConfig { public class AgentRegistryBeanConfig {
@Bean @Bean
public AgentRegistryService agentRegistryService(AgentRegistryConfig config) { public AgentRegistryService agentRegistryService(AgentRegistryConfig config,
com.cameleer.server.app.license.LicenseEnforcer enforcer) {
return new AgentRegistryService( return new AgentRegistryService(
config.getStaleThresholdMs(), config.getStaleThresholdMs(),
config.getDeadThresholdMs(), config.getDeadThresholdMs(),
config.getCommandExpiryMs() config.getCommandExpiryMs(),
current -> enforcer.assertWithinCap("max_agents", current, 1)
); );
} }

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +14,7 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@@ -33,10 +35,18 @@ class AgentCommandControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Lift max_agents cap so this IT (which registers many agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
agentJwt = securityHelper.registerTestAgent("test-agent-command-it"); agentJwt = securityHelper.registerTestAgent("test-agent-command-it");
operatorJwt = securityHelper.operatorToken(); operatorJwt = securityHelper.operatorToken();
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId, String name, String application) { private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """ String json = """
{ {

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +14,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
class AgentRegistrationControllerIT extends AbstractPostgresIT { class AgentRegistrationControllerIT extends AbstractPostgresIT {
@@ -31,10 +34,18 @@ class AgentRegistrationControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Lift max_agents cap so this IT (which registers many agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-registration-it"); jwt = securityHelper.registerTestAgent("test-agent-registration-it");
viewerJwt = securityHelper.viewerToken(); viewerJwt = securityHelper.viewerToken();
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId, String name) { private ResponseEntity<String> registerAgent(String agentId, String name) {
String json = """ String json = """
{ {

View File

@@ -3,6 +3,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -20,6 +21,7 @@ import java.net.http.HttpResponse;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@@ -48,10 +50,18 @@ class AgentSseControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Lift max_agents cap so this IT (which registers many agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-sse-it"); jwt = securityHelper.registerTestAgent("test-agent-sse-it");
operatorJwt = securityHelper.operatorToken(); operatorJwt = securityHelper.operatorToken();
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId, String name, String application) { private ResponseEntity<String> registerAgent(String agentId, String name, String application) {
String json = """ String json = """
{ {

View File

@@ -3,6 +3,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.ingestion.IngestionService; import com.cameleer.server.core.ingestion.IngestionService;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +14,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.TestPropertySource;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/** /**
@@ -45,10 +48,18 @@ class BackpressureIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
String jwt = securityHelper.registerTestAgent("test-agent-backpressure-it"); String jwt = securityHelper.registerTestAgent("test-agent-backpressure-it");
authHeaders = securityHelper.authHeaders(jwt); authHeaders = securityHelper.authHeaders(jwt);
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void whenMetricsBufferFull_returns503WithRetryAfter() { void whenMetricsBufferFull_returns503WithRetryAfter() {
// Fill the metrics buffer completely with a batch of 5 // Fill the metrics buffer completely with a batch of 5

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.api.TestInstance;
@@ -15,6 +16,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -49,6 +52,9 @@ class DetailControllerIT extends AbstractPostgresIT {
*/ */
@BeforeAll @BeforeAll
void seedTestData() { void seedTestData() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-detail-it"); jwt = securityHelper.registerTestAgent("test-agent-detail-it");
viewerJwt = securityHelper.viewerToken(); viewerJwt = securityHelper.viewerToken();
@@ -231,4 +237,9 @@ class DetailControllerIT extends AbstractPostgresIT {
new HttpEntity<>(headers), new HttpEntity<>(headers),
String.class); String.class);
} }
@AfterAll
void tearDown() {
securityHelper.clearTestLicense();
}
} }

View File

@@ -2,6 +2,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -12,6 +13,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -29,11 +32,19 @@ class DiagramControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
String jwt = securityHelper.registerTestAgent("test-agent-diagram-it"); String jwt = securityHelper.registerTestAgent("test-agent-diagram-it");
authHeaders = securityHelper.authHeaders(jwt); authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void postSingleDiagram_returns202() { void postSingleDiagram_returns202() {
String json = """ String json = """

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -41,6 +44,9 @@ class DiagramRenderControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void seedDiagram() { void seedDiagram() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-diagram-render-it"); jwt = securityHelper.registerTestAgent("test-agent-diagram-render-it");
viewerJwt = securityHelper.viewerToken(); viewerJwt = securityHelper.viewerToken();
@@ -115,6 +121,11 @@ class DiagramRenderControllerIT extends AbstractPostgresIT {
}); });
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void getSvg_withAcceptHeader_returnsSvg() { void getSvg_withAcceptHeader_returnsSvg() {
HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt); HttpHeaders headers = securityHelper.authHeadersNoBody(viewerJwt);

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -38,11 +41,19 @@ class ExecutionControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
String jwt = securityHelper.registerTestAgent("test-agent-execution-it"); String jwt = securityHelper.registerTestAgent("test-agent-execution-it");
authHeaders = securityHelper.authHeaders(jwt); authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void postSingleExecution_returns202() { void postSingleExecution_returns202() {
String json = """ String json = """

View File

@@ -2,6 +2,7 @@ package com.cameleer.server.app.controller;
import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -10,6 +11,8 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/** /**
@@ -28,9 +31,17 @@ class ForwardCompatIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-forward-compat-it"); jwt = securityHelper.registerTestAgent("test-agent-forward-compat-it");
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void unknownFieldsInRequestBodyDoNotCauseError() { void unknownFieldsInRequestBodyDoNotCauseError() {
// Valid ExecutionChunk plus extra fields a future agent version // Valid ExecutionChunk plus extra fields a future agent version

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -34,12 +37,20 @@ class MetricsControllerIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
agentId = "test-agent-metrics-it"; agentId = "test-agent-metrics-it";
String jwt = securityHelper.registerTestAgent(agentId); String jwt = securityHelper.registerTestAgent(agentId);
authHeaders = securityHelper.authHeaders(jwt); authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void postMetrics_returns202() { void postMetrics_returns202() {
String json = """ String json = """

View File

@@ -14,6 +14,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -42,6 +44,10 @@ class SearchControllerIT extends AbstractPostgresIT {
*/ */
@BeforeEach @BeforeEach
void seedTestData() { void seedTestData() {
// Lift max_agents cap unconditionally so this IT (which registers an agent on first
// seed) isn't gated by license enforcement on this run or any sibling that follows.
// Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
if (seeded) return; if (seeded) return;
seeded = true; seeded = true;
jwt = securityHelper.registerTestAgent("test-agent-search-it"); jwt = securityHelper.registerTestAgent("test-agent-search-it");

View File

@@ -2,6 +2,7 @@ package com.cameleer.server.app.interceptor;
import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -11,6 +12,8 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod; import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/** /**
@@ -30,9 +33,17 @@ class ProtocolVersionIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
jwt = securityHelper.registerTestAgent("test-agent-protocol-it"); jwt = securityHelper.registerTestAgent("test-agent-protocol-it");
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void requestWithoutProtocolHeaderReturns400() { void requestWithoutProtocolHeaderReturns400() {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();

View File

@@ -0,0 +1,127 @@
package com.cameleer.server.app.license;
import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.agent.AgentInfo;
import com.cameleer.server.core.agent.AgentRegistryService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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.HttpStatus;
import org.springframework.http.ResponseEntity;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Verifies that the {@code max_agents} cap is enforced at
* {@code POST /api/v1/agents/register} via the {@link com.cameleer.server.core.runtime.CreateGuard}
* wired into {@link AgentRegistryService}. The cap fires only on NEW registrations — re-registers
* of an already-registered agent bypass the check (they don't grow the registry).
*
* <p>This IT installs a synthetic license that lowers {@code max_agents} to {@code 2} so the cap
* can be exercised in a few HTTP calls. The default tier ({@code max_agents = 5}) is intentionally
* not exercised here because sibling agent ITs share the Spring context's in-memory registry and
* would interfere; the structured 403 envelope (also produced by
* {@link LicenseExceptionAdvice}) is identical regardless of the underlying limit value.</p>
*/
class AgentCapEnforcementIT extends AbstractPostgresIT {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@Autowired
private AgentRegistryService agentRegistryService;
@BeforeEach
void setUp() {
// The registry is in-memory and shared across @SpringBootTest reuse boundaries; sibling
// ITs (AgentRegistrationControllerIT, AgentSseControllerIT, …) leave residue here.
clearRegistry();
// Lower max_agents to 2 so the cap rejection lands on the third register call.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 2));
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
clearRegistry();
}
private void clearRegistry() {
List<AgentInfo> all = agentRegistryService.findAll();
for (AgentInfo a : all) {
agentRegistryService.deregister(a.instanceId());
}
}
private ResponseEntity<String> register(String agentId) {
String json = """
{
"instanceId": "%s",
"applicationId": "test-cap",
"environmentId": "default",
"version": "1.0.0",
"routeIds": [],
"capabilities": {}
}
""".formatted(agentId);
return restTemplate.postForEntity(
"/api/v1/agents/register",
new HttpEntity<>(json, securityHelper.bootstrapHeaders()),
String.class);
}
@Test
void registerBeyondCap_returns403WithStateAndMessage() throws Exception {
// Two registrations succeed (cap = 2).
assertThat(register("cap-it-agent-1").getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(register("cap-it-agent-2").getStatusCode()).isEqualTo(HttpStatus.OK);
// The third registration must be rejected with the structured 403 envelope.
ResponseEntity<String> third = register("cap-it-agent-3");
assertThat(third.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
JsonNode body = objectMapper.readTree(third.getBody());
assertThat(body.path("error").asText()).isEqualTo("license cap reached");
assertThat(body.path("limit").asText()).isEqualTo("max_agents");
assertThat(body.path("cap").asInt()).isEqualTo(2);
// We installed a synthetic license, so state is ACTIVE (not ABSENT).
assertThat(body.path("state").asText()).isEqualTo("ACTIVE");
assertThat(body.has("message")).isTrue();
assertThat(body.path("message").asText()).isNotBlank();
// And the third agent must NOT be present in the registry.
assertThat(agentRegistryService.findById("cap-it-agent-3")).isNull();
}
@Test
void reRegisterAtCap_bypassesGuardAndReturns200() {
// Fill to the cap.
assertThat(register("cap-it-rereg-1").getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(register("cap-it-rereg-2").getStatusCode()).isEqualTo(HttpStatus.OK);
// Re-register an existing agent — must succeed even though we're at-cap, because
// re-registers don't grow the registry. This is the explicit design of the guard
// placement inside the (existing == null) branch of agents.compute(...).
ResponseEntity<String> reRegister = register("cap-it-rereg-1");
assertThat(reRegister.getStatusCode()).isEqualTo(HttpStatus.OK);
// And a fresh registration is still rejected.
ResponseEntity<String> third = register("cap-it-rereg-3");
assertThat(third.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
}

View File

@@ -5,6 +5,8 @@ import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.security.JwtService; import com.cameleer.server.core.security.JwtService;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.client.TestRestTemplate;
@@ -15,6 +17,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/** /**
@@ -34,6 +38,18 @@ class JwtRefreshIT extends AbstractPostgresIT {
@Autowired @Autowired
private JwtService jwtService; private JwtService jwtService;
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private JsonNode registerAndGetTokens(String agentId) throws Exception { private JsonNode registerAndGetTokens(String agentId) throws Exception {
String json = """ String json = """
{ {

View File

@@ -1,8 +1,11 @@
package com.cameleer.server.app.security; package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.client.TestRestTemplate;
@@ -13,6 +16,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/** /**
@@ -27,6 +32,21 @@ class RegistrationSecurityIT extends AbstractPostgresIT {
@Autowired @Autowired
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@Autowired
private TestSecurityHelper securityHelper;
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private ResponseEntity<String> registerAgent(String agentId) { private ResponseEntity<String> registerAgent(String agentId) {
String json = """ String json = """
{ {

View File

@@ -2,6 +2,7 @@ package com.cameleer.server.app.security;
import com.cameleer.server.app.AbstractPostgresIT; import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -13,6 +14,8 @@ import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
/** /**
@@ -31,10 +34,18 @@ class SecurityFilterIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Lift max_agents cap so this IT (which registers agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
securityHelper.registerTestAgent("test-agent-security-filter-it"); securityHelper.registerTestAgent("test-agent-security-filter-it");
viewerJwt = securityHelper.viewerToken(); viewerJwt = securityHelper.viewerToken();
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void protectedEndpoint_withoutJwt_returns401or403() { void protectedEndpoint_withoutJwt_returns401or403() {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();

View File

@@ -5,6 +5,8 @@ import com.cameleer.server.app.TestSecurityHelper;
import com.cameleer.server.core.security.Ed25519SigningService; import com.cameleer.server.core.security.Ed25519SigningService;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.web.client.TestRestTemplate; import org.springframework.boot.test.web.client.TestRestTemplate;
@@ -29,6 +31,7 @@ import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Base64; import java.util.Base64;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
@@ -62,6 +65,18 @@ class SseSigningIT extends AbstractPostgresIT {
@LocalServerPort @LocalServerPort
private int port; private int port;
@BeforeEach
void setUp() {
// Lift max_agents cap so this IT (which registers agents per test) isn't gated
// by license enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
}
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
private HttpHeaders protocolHeaders() { private HttpHeaders protocolHeaders() {
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON); headers.setContentType(MediaType.APPLICATION_JSON);

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -40,11 +43,19 @@ class DiagramLinkingIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
String jwt = securityHelper.registerTestAgent(agentId); String jwt = securityHelper.registerTestAgent(agentId);
authHeaders = securityHelper.authHeaders(jwt); authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void diagramHashPopulated_whenRouteGraphExistsBeforeExecution() throws Exception { void diagramHashPopulated_whenRouteGraphExistsBeforeExecution() throws Exception {
String graphJson = """ String graphJson = """

View File

@@ -4,6 +4,7 @@ import com.cameleer.server.app.AbstractPostgresIT;
import com.cameleer.server.app.TestSecurityHelper; import com.cameleer.server.app.TestSecurityHelper;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -14,6 +15,8 @@ import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import java.util.Map;
import static java.util.concurrent.TimeUnit.SECONDS; import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await; import static org.awaitility.Awaitility.await;
@@ -41,11 +44,19 @@ class IngestionSchemaIT extends AbstractPostgresIT {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
// Lift max_agents cap so this IT (which registers an agent) isn't gated by license
// enforcement. Cap behaviour itself is exercised by AgentCapEnforcementIT.
securityHelper.installSyntheticUnsignedLicense(Map.of("max_agents", 100));
String jwt = securityHelper.registerTestAgent(agentId); String jwt = securityHelper.registerTestAgent(agentId);
authHeaders = securityHelper.authHeaders(jwt); authHeaders = securityHelper.authHeaders(jwt);
viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken()); viewerHeaders = securityHelper.authHeadersNoBody(securityHelper.viewerToken());
} }
@AfterEach
void tearDown() {
securityHelper.clearTestLicense();
}
@Test @Test
void processorTreeMetadata_depthsAndParentIdsCorrect() throws Exception { void processorTreeMetadata_depthsAndParentIdsCorrect() throws Exception {
String json = """ String json = """

View File

@@ -1,5 +1,6 @@
package com.cameleer.server.core.agent; package com.cameleer.server.core.agent;
import com.cameleer.server.core.runtime.CreateGuard;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -29,6 +30,7 @@ public class AgentRegistryService {
private final long staleThresholdMs; private final long staleThresholdMs;
private final long deadThresholdMs; private final long deadThresholdMs;
private final long commandExpiryMs; private final long commandExpiryMs;
private final CreateGuard registerGuard;
private final ConcurrentHashMap<String, AgentInfo> agents = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, AgentInfo> agents = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, ConcurrentLinkedQueue<AgentCommand>> commands = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, ConcurrentLinkedQueue<AgentCommand>> commands = new ConcurrentHashMap<>();
@@ -36,10 +38,18 @@ public class AgentRegistryService {
private volatile AgentEventListener eventListener; private volatile AgentEventListener eventListener;
/** Backwards-compatible 3-arg ctor (no enforcement). Used by tests. */
public AgentRegistryService(long staleThresholdMs, long deadThresholdMs, long commandExpiryMs) { public AgentRegistryService(long staleThresholdMs, long deadThresholdMs, long commandExpiryMs) {
this(staleThresholdMs, deadThresholdMs, commandExpiryMs, CreateGuard.NOOP);
}
/** Production ctor with license-cap enforcement on new registrations. */
public AgentRegistryService(long staleThresholdMs, long deadThresholdMs, long commandExpiryMs,
CreateGuard registerGuard) {
this.staleThresholdMs = staleThresholdMs; this.staleThresholdMs = staleThresholdMs;
this.deadThresholdMs = deadThresholdMs; this.deadThresholdMs = deadThresholdMs;
this.commandExpiryMs = commandExpiryMs; this.commandExpiryMs = commandExpiryMs;
this.registerGuard = registerGuard;
} }
/** /**
@@ -55,7 +65,9 @@ public class AgentRegistryService {
return agents.compute(id, (key, existing) -> { return agents.compute(id, (key, existing) -> {
if (existing != null) { if (existing != null) {
// Re-registration: update metadata, reset to LIVE // Re-registration: update metadata, reset to LIVE.
// Re-registers bypass the license cap check — they don't grow the registry,
// and rejecting them would orphan an agent that already counts against the cap.
log.info("Agent {} re-registering (was {})", id, existing.state()); log.info("Agent {} re-registering (was {})", id, existing.state());
return existing return existing
.withMetadata(name, application, environmentId, version, List.copyOf(routeIds), Map.copyOf(capabilities)) .withMetadata(name, application, environmentId, version, List.copyOf(routeIds), Map.copyOf(capabilities))
@@ -64,11 +76,25 @@ public class AgentRegistryService {
.withRegisteredAt(now) .withRegisteredAt(now)
.withStaleTransitionTime(null); .withStaleTransitionTime(null);
} }
// NEW registration — consult the cap. ConcurrentHashMap.compute propagates the
// exception thrown here, so the controller advice (LicenseExceptionAdvice) maps
// LicenseCapExceededException to 403 with the structured envelope.
registerGuard.check(liveAgentCount());
log.info("Agent {} registered (name={}, application={}, env={})", id, name, application, environmentId); log.info("Agent {} registered (name={}, application={}, env={})", id, name, application, environmentId);
return newAgent; return newAgent;
}); });
} }
/**
* Live-only agent count for license-cap enforcement.
* STALE/DEAD/SHUTDOWN agents are excluded so the cap reflects the working fleet, not
* historical residue. Re-registers of an existing agent revive it via the
* {@code existing != null} branch in {@link #register}, so this count never double-counts.
*/
private long liveAgentCount() {
return agents.values().stream().filter(a -> a.state() == AgentState.LIVE).count();
}
/** /**
* Process a heartbeat from an agent. * Process a heartbeat from an agent.
* Updates lastHeartbeat, routeIds (if provided), capabilities (if provided), * Updates lastHeartbeat, routeIds (if provided), capabilities (if provided),