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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user