diff --git a/.planning/phases/03-agent-registry-sse-push/deferred-items.md b/.planning/phases/03-agent-registry-sse-push/deferred-items.md new file mode 100644 index 00000000..e7d89000 --- /dev/null +++ b/.planning/phases/03-agent-registry-sse-push/deferred-items.md @@ -0,0 +1,5 @@ +# Phase 3 Deferred Items + +## Pre-existing Test Flakiness + +- **DiagramRenderControllerIT.seedDiagram** - EmptyResultDataAccess error (expects 1 row, gets 0). This is a pre-existing ClickHouse timing issue not caused by Phase 3 changes. The test relies on data being flushed and available before the assertion, which can fail under timing pressure. diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java index 3ce2e43f..bf271ffc 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/Cameleer3ServerApplication.java @@ -1,5 +1,6 @@ package com.cameleer3.server.app; +import com.cameleer3.server.app.config.AgentRegistryConfig; import com.cameleer3.server.app.config.IngestionConfig; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @@ -16,7 +17,7 @@ import org.springframework.scheduling.annotation.EnableScheduling; "com.cameleer3.server.core" }) @EnableScheduling -@EnableConfigurationProperties(IngestionConfig.class) +@EnableConfigurationProperties({IngestionConfig.class, AgentRegistryConfig.class}) public class Cameleer3ServerApplication { public static void main(String[] args) { diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java new file mode 100644 index 00000000..36d48205 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/agent/AgentLifecycleMonitor.java @@ -0,0 +1,36 @@ +package com.cameleer3.server.app.agent; + +import com.cameleer3.server.core.agent.AgentRegistryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +/** + * Periodic task that checks agent lifecycle and expires old commands. + *
+ * Runs on a configurable fixed delay (default 10 seconds). Transitions + * agents LIVE -> STALE -> DEAD based on heartbeat timing, and removes + * expired pending commands. + */ +@Component +public class AgentLifecycleMonitor { + + private static final Logger log = LoggerFactory.getLogger(AgentLifecycleMonitor.class); + + private final AgentRegistryService registryService; + + public AgentLifecycleMonitor(AgentRegistryService registryService) { + this.registryService = registryService; + } + + @Scheduled(fixedDelayString = "${agent-registry.lifecycle-check-interval-ms:10000}") + public void checkLifecycle() { + try { + registryService.checkLifecycle(); + registryService.expireOldCommands(); + } catch (Exception e) { + log.error("Error during agent lifecycle check", e); + } + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java new file mode 100644 index 00000000..f59e536f --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryBeanConfig.java @@ -0,0 +1,23 @@ +package com.cameleer3.server.app.config; + +import com.cameleer3.server.core.agent.AgentRegistryService; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Creates the {@link AgentRegistryService} bean. + *
+ * Follows the established pattern: core module plain class, app module bean config. + */ +@Configuration +public class AgentRegistryBeanConfig { + + @Bean + public AgentRegistryService agentRegistryService(AgentRegistryConfig config) { + return new AgentRegistryService( + config.getStaleThresholdMs(), + config.getDeadThresholdMs(), + config.getCommandExpiryMs() + ); + } +} diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryConfig.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryConfig.java new file mode 100644 index 00000000..7861abd2 --- /dev/null +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/config/AgentRegistryConfig.java @@ -0,0 +1,68 @@ +package com.cameleer3.server.app.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Configuration properties for the agent registry. + * Bound from the {@code agent-registry.*} namespace in application.yml. + *
+ * Registered via {@code @EnableConfigurationProperties} on the application class.
+ */
+@ConfigurationProperties(prefix = "agent-registry")
+public class AgentRegistryConfig {
+
+ private long heartbeatIntervalMs = 30_000;
+ private long staleThresholdMs = 90_000;
+ private long deadThresholdMs = 300_000;
+ private long pingIntervalMs = 15_000;
+ private long commandExpiryMs = 60_000;
+ private long lifecycleCheckIntervalMs = 10_000;
+
+ public long getHeartbeatIntervalMs() {
+ return heartbeatIntervalMs;
+ }
+
+ public void setHeartbeatIntervalMs(long heartbeatIntervalMs) {
+ this.heartbeatIntervalMs = heartbeatIntervalMs;
+ }
+
+ public long getStaleThresholdMs() {
+ return staleThresholdMs;
+ }
+
+ public void setStaleThresholdMs(long staleThresholdMs) {
+ this.staleThresholdMs = staleThresholdMs;
+ }
+
+ public long getDeadThresholdMs() {
+ return deadThresholdMs;
+ }
+
+ public void setDeadThresholdMs(long deadThresholdMs) {
+ this.deadThresholdMs = deadThresholdMs;
+ }
+
+ public long getPingIntervalMs() {
+ return pingIntervalMs;
+ }
+
+ public void setPingIntervalMs(long pingIntervalMs) {
+ this.pingIntervalMs = pingIntervalMs;
+ }
+
+ public long getCommandExpiryMs() {
+ return commandExpiryMs;
+ }
+
+ public void setCommandExpiryMs(long commandExpiryMs) {
+ this.commandExpiryMs = commandExpiryMs;
+ }
+
+ public long getLifecycleCheckIntervalMs() {
+ return lifecycleCheckIntervalMs;
+ }
+
+ public void setLifecycleCheckIntervalMs(long lifecycleCheckIntervalMs) {
+ this.lifecycleCheckIntervalMs = lifecycleCheckIntervalMs;
+ }
+}
diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java
new file mode 100644
index 00000000..1007d686
--- /dev/null
+++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/AgentRegistrationController.java
@@ -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