feat(03-02): SSE connection manager, SSE endpoint, and command controller

- SseConnectionManager with per-agent SseEmitter, ping keepalive, event delivery
- AgentSseController GET /{id}/events SSE endpoint with Last-Event-ID support
- AgentCommandController with single/group/broadcast command targeting + ack
- WebConfig excludes SSE events path from protocol version interceptor

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-11 18:45:47 +01:00
parent af0af9ce38
commit 5746886a0b
4 changed files with 407 additions and 1 deletions

View File

@@ -0,0 +1,157 @@
package com.cameleer3.server.app.agent;
import com.cameleer3.server.app.config.AgentRegistryConfig;
import com.cameleer3.server.core.agent.AgentCommand;
import com.cameleer3.server.core.agent.AgentEventListener;
import com.cameleer3.server.core.agent.AgentRegistryService;
import jakarta.annotation.PostConstruct;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* Manages per-agent SSE connections and delivers commands via Server-Sent Events.
* <p>
* Implements {@link AgentEventListener} so the core {@link AgentRegistryService}
* can notify this component when a command is ready for delivery, without depending
* on Spring or SSE classes.
*/
@Component
public class SseConnectionManager implements AgentEventListener {
private static final Logger log = LoggerFactory.getLogger(SseConnectionManager.class);
private final ConcurrentHashMap<String, SseEmitter> emitters = new ConcurrentHashMap<>();
private final AgentRegistryService registryService;
private final AgentRegistryConfig config;
public SseConnectionManager(AgentRegistryService registryService, AgentRegistryConfig config) {
this.registryService = registryService;
this.config = config;
}
@PostConstruct
void init() {
registryService.setEventListener(this);
log.info("SseConnectionManager registered as AgentEventListener");
}
/**
* Create an SSE connection for the given agent.
* Replaces any existing connection (completing the old emitter).
*
* @param agentId the agent identifier
* @return the new SseEmitter
*/
public SseEmitter connect(String agentId) {
SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
SseEmitter old = emitters.put(agentId, emitter);
if (old != null) {
log.debug("Replacing existing SSE connection for agent {}", agentId);
old.complete();
}
// Remove from map only if the emitter is still the current one (reference equality)
emitter.onCompletion(() -> {
emitters.remove(agentId, emitter);
log.debug("SSE connection completed for agent {}", agentId);
});
emitter.onTimeout(() -> {
emitters.remove(agentId, emitter);
log.debug("SSE connection timed out for agent {}", agentId);
});
emitter.onError(ex -> {
emitters.remove(agentId, emitter);
log.debug("SSE connection error for agent {}: {}", agentId, ex.getMessage());
});
log.info("SSE connection established for agent {}", agentId);
return emitter;
}
/**
* Send an event to a specific agent's SSE stream.
*
* @param agentId the target agent
* @param eventId the event ID (for Last-Event-ID reconnection)
* @param eventType the SSE event name
* @param data the event data (serialized as JSON)
* @return true if the event was sent successfully, false if the agent is not connected or send failed
*/
public boolean sendEvent(String agentId, String eventId, String eventType, Object data) {
SseEmitter emitter = emitters.get(agentId);
if (emitter == null) {
return false;
}
try {
emitter.send(SseEmitter.event()
.id(eventId)
.name(eventType)
.data(data, MediaType.APPLICATION_JSON));
return true;
} catch (IOException e) {
log.debug("Failed to send SSE event to agent {}: {}", agentId, e.getMessage());
emitters.remove(agentId, emitter);
return false;
}
}
/**
* Send a ping keepalive comment to all connected agents.
*/
public void sendPingToAll() {
for (Map.Entry<String, SseEmitter> entry : emitters.entrySet()) {
String agentId = entry.getKey();
SseEmitter emitter = entry.getValue();
try {
emitter.send(SseEmitter.event().comment("ping"));
} catch (IOException e) {
log.debug("Ping failed for agent {}, removing connection", agentId);
emitters.remove(agentId, emitter);
}
}
}
/**
* Check if an agent has an active SSE connection.
*/
public boolean isConnected(String agentId) {
return emitters.containsKey(agentId);
}
/**
* Called by the registry when a command is ready for an agent.
* Attempts to deliver via SSE; if successful, marks as DELIVERED.
* If the agent is not connected, the command stays PENDING.
*/
@Override
public void onCommandReady(String agentId, AgentCommand command) {
String eventType = command.type().name().toLowerCase().replace('_', '-');
boolean sent = sendEvent(agentId, command.id(), eventType, command.payload());
if (sent) {
registryService.markDelivered(agentId, command.id());
log.debug("Command {} ({}) delivered to agent {} via SSE", command.id(), eventType, agentId);
} else {
log.debug("Agent {} not connected, command {} stays PENDING", agentId, command.id());
}
}
/**
* Scheduled ping keepalive to all connected agents.
*/
@Scheduled(fixedDelayString = "${agent-registry.ping-interval-ms:15000}")
void pingAll() {
if (!emitters.isEmpty()) {
sendPingToAll();
}
}
}