001/* 002 * Copyright (c) 2026 Singular 003 * SPDX-License-Identifier: MIT 004 */ 005package ai.singlr.runtime; 006 007import ai.singlr.core.common.Ids; 008import ai.singlr.core.common.Strings; 009import ai.singlr.session.AgentSession; 010import ai.singlr.session.QueryEvent; 011import ai.singlr.session.ResultMessage; 012import ai.singlr.session.SessionOptions; 013import ai.singlr.session.UserMessage; 014import io.helidon.http.Status; 015import io.helidon.http.sse.SseEvent; 016import io.helidon.webserver.CloseConnectionException; 017import io.helidon.webserver.http.HttpRules; 018import io.helidon.webserver.http.HttpService; 019import io.helidon.webserver.http.ServerRequest; 020import io.helidon.webserver.http.ServerResponse; 021import io.helidon.webserver.sse.SseSink; 022import java.io.IOException; 023import java.net.SocketException; 024import java.util.Map; 025import java.util.Objects; 026import java.util.Optional; 027import java.util.concurrent.CompletableFuture; 028import java.util.concurrent.CountDownLatch; 029import java.util.concurrent.ExecutionException; 030import java.util.concurrent.Flow; 031import java.util.concurrent.TimeUnit; 032import java.util.concurrent.TimeoutException; 033import java.util.concurrent.atomic.AtomicReference; 034import java.util.function.Function; 035import java.util.logging.Level; 036import java.util.logging.Logger; 037import tools.jackson.databind.ObjectMapper; 038 039/** 040 * Helidon {@link HttpService} that exposes one {@link SessionRegistry}'s sessions over HTTP. The 041 * five Phase 1 routes mirror the spec §15.1 sketch and are mounted by the caller under whatever 042 * prefix fits the deployment ({@code routing.register("/v1", new AgentHttpService(...))}). 043 * 044 * <ul> 045 * <li>{@code POST /sessions} — create a fresh session; returns {@code {sessionId, eventsUrl}} 046 * with {@code 201 Created}. 047 * <li>{@code POST /sessions/{sessionId}/messages} — body {@code {text: "..."}}; queues the 048 * message and returns {@code 202 Accepted}. 049 * <li>{@code POST /sessions/{sessionId}/interrupt} — body {@code {reason: "..."}}; queues a 050 * synthetic interrupt message and returns {@code 202 Accepted}. 051 * <li>{@code GET /sessions/{sessionId}/events} — opens an SSE stream of {@link QueryEvent}s. The 052 * handler blocks the request thread until the publisher signals {@code onComplete} or the 053 * client disconnects. 054 * <li>{@code GET /sessions/{sessionId}/result?timeout=<seconds>} — long-poll for the terminal 055 * {@link ai.singlr.session.ResultMessage ResultMessage}. Returns {@code 200 OK} with body 056 * {@code {type: "<SubtypeName>", result: <record-fields>}} when terminal; {@code 204 No 057 * Content} when the {@code timeout} elapses with no terminal. {@code timeout} defaults to 60 058 * s and is clamped to {@code [0, 300]} so a single request cannot pin a server thread longer 059 * than five minutes. 060 * <li>{@code DELETE /sessions/{sessionId}} — closes and unregisters the session; returns {@code 061 * 204 No Content}. 062 * </ul> 063 * 064 * <p>The {@code optionsFactory} is the seam through which deployments choose how a new session is 065 * configured: the runtime takes the generated session id and produces a fully-populated {@link 066 * SessionOptions}. For Phase 1 the typical impl returns the same {@link ai.singlr.core.model.Model 067 * Model} for every session. 068 * 069 * <h2>Thread-safety</h2> 070 * 071 * Thread-safe. Each request runs on its own virtual thread; {@link SessionRegistry} synchronises 072 * shared session state. 073 */ 074public final class AgentHttpService implements HttpService { 075 076 private static final Logger LOGGER = Logger.getLogger(AgentHttpService.class.getName()); 077 078 private final SessionRegistry registry; 079 private final Function<String, SessionOptions> optionsFactory; 080 private final ObjectMapper objectMapper; 081 private final String eventsPathPrefix; 082 083 /** 084 * Build a service. 085 * 086 * @param registry registry of live sessions; non-null 087 * @param optionsFactory function that maps a generated session id to a fully-configured {@link 088 * SessionOptions}; non-null 089 * @param objectMapper mapper for request/response bodies; non-null 090 * @param eventsPathPrefix prefix used to build the {@code eventsUrl} returned by {@code POST 091 * /sessions} (e.g. {@code "/v1"}); non-null 092 * @throws NullPointerException if any argument is null 093 */ 094 public AgentHttpService( 095 SessionRegistry registry, 096 Function<String, SessionOptions> optionsFactory, 097 ObjectMapper objectMapper, 098 String eventsPathPrefix) { 099 this.registry = Objects.requireNonNull(registry, "registry must not be null"); 100 this.optionsFactory = Objects.requireNonNull(optionsFactory, "optionsFactory must not be null"); 101 this.objectMapper = Objects.requireNonNull(objectMapper, "objectMapper must not be null"); 102 this.eventsPathPrefix = 103 Objects.requireNonNull(eventsPathPrefix, "eventsPathPrefix must not be null"); 104 } 105 106 @Override 107 public void routing(HttpRules rules) { 108 rules.post("/sessions", this::createHandler); 109 rules.post("/sessions/{sessionId}/messages", this::messageHandler); 110 rules.post("/sessions/{sessionId}/interrupt", this::interruptHandler); 111 rules.get("/sessions/{sessionId}/events", this::eventsHandler); 112 rules.get("/sessions/{sessionId}/result", this::resultHandler); 113 rules.delete("/sessions/{sessionId}", this::deleteHandler); 114 } 115 116 // ── handlers ──────────────────────────────────────────────────────────── 117 118 private void createHandler(ServerRequest req, ServerResponse resp) { 119 var sessionId = "sess-" + Ids.newId(); 120 SessionOptions options; 121 try { 122 options = optionsFactory.apply(sessionId); 123 } catch (RuntimeException e) { 124 LOGGER.log(Level.WARNING, "optionsFactory failed for session " + sessionId, e); 125 resp.status(Status.INTERNAL_SERVER_ERROR_500) 126 .send(Map.of("error", "session options factory failed: " + e.getMessage())); 127 return; 128 } 129 if (!options.sessionId().equals(sessionId)) { 130 LOGGER.warning( 131 "optionsFactory ignored the supplied sessionId; using factory-provided id " 132 + options.sessionId()); 133 } 134 registry.create(options); 135 resp.status(Status.CREATED_201) 136 .send( 137 Map.of( 138 "sessionId", 139 options.sessionId(), 140 "eventsUrl", 141 eventsPathPrefix + "/sessions/" + options.sessionId() + "/events")); 142 } 143 144 private void messageHandler(ServerRequest req, ServerResponse resp) { 145 var sessionOpt = findSession(req, resp); 146 if (sessionOpt.isEmpty()) { 147 return; 148 } 149 Map<String, Object> body; 150 try { 151 body = readJsonBody(req); 152 } catch (JacksonRuntimeException e) { 153 resp.status(Status.BAD_REQUEST_400).send(Map.of("error", "invalid JSON body")); 154 return; 155 } 156 var text = body.get("text"); 157 if (!(text instanceof String s) || Strings.isBlank(s)) { 158 resp.status(Status.BAD_REQUEST_400) 159 .send(Map.of("error", "'text' field must be a non-blank string")); 160 return; 161 } 162 try { 163 sessionOpt.get().send(UserMessage.text(s)); 164 } catch (IllegalStateException e) { 165 resp.status(Status.CONFLICT_409).send(Map.of("error", e.getMessage())); 166 return; 167 } 168 resp.status(Status.ACCEPTED_202).send(); 169 } 170 171 private void interruptHandler(ServerRequest req, ServerResponse resp) { 172 var sessionOpt = findSession(req, resp); 173 if (sessionOpt.isEmpty()) { 174 return; 175 } 176 Map<String, Object> body; 177 try { 178 body = readJsonBody(req); 179 } catch (JacksonRuntimeException e) { 180 resp.status(Status.BAD_REQUEST_400).send(Map.of("error", "invalid JSON body")); 181 return; 182 } 183 var reason = body.get("reason"); 184 if (!(reason instanceof String r) || Strings.isBlank(r)) { 185 resp.status(Status.BAD_REQUEST_400) 186 .send(Map.of("error", "'reason' field must be a non-blank string")); 187 return; 188 } 189 try { 190 sessionOpt.get().interrupt(r); 191 } catch (IllegalStateException e) { 192 resp.status(Status.CONFLICT_409).send(Map.of("error", e.getMessage())); 193 return; 194 } 195 resp.status(Status.ACCEPTED_202).send(); 196 } 197 198 private void eventsHandler(ServerRequest req, ServerResponse resp) { 199 var sessionOpt = findSession(req, resp); 200 if (sessionOpt.isEmpty()) { 201 return; 202 } 203 var session = sessionOpt.get(); 204 var sink = resp.sink(SseSink.TYPE); 205 var done = new CountDownLatch(1); 206 var subscription = new AtomicReference<Flow.Subscription>(); 207 session 208 .events() 209 .subscribe( 210 new Flow.Subscriber<QueryEvent>() { 211 @Override 212 public void onSubscribe(Flow.Subscription s) { 213 subscription.set(s); 214 s.request(Long.MAX_VALUE); 215 // Synthetic Ready event so clients can synchronously confirm subscription is 216 // live before triggering work — closes the race between Helidon writing 200 OK 217 // and the subscriber actually registering on the SubmissionPublisher. 218 try { 219 sink.emit(SseEvent.builder().name("Ready").data("{}").build()); 220 } catch (Exception ignored) { 221 // sink failures are handled in onNext below 222 } 223 } 224 225 @Override 226 public void onNext(QueryEvent event) { 227 try { 228 sink.emit( 229 SseEvent.builder() 230 .name(eventName(event)) 231 .data(objectMapper.writeValueAsString(event)) 232 .build()); 233 } catch (Exception ex) { 234 if (!isDisconnect(ex)) { 235 LOGGER.log( 236 Level.WARNING, "SSE emit failed for session " + session.sessionId(), ex); 237 } 238 var s = subscription.get(); 239 if (s != null) { 240 s.cancel(); 241 } 242 done.countDown(); 243 } 244 } 245 246 @Override 247 public void onError(Throwable t) { 248 LOGGER.log( 249 Level.WARNING, 250 "events publisher errored for session " + session.sessionId(), 251 t); 252 done.countDown(); 253 } 254 255 @Override 256 public void onComplete() { 257 done.countDown(); 258 } 259 }); 260 try { 261 done.await(); 262 } catch (InterruptedException e) { 263 Thread.currentThread().interrupt(); 264 } finally { 265 try { 266 sink.close(); 267 } catch (Exception ignored) { 268 // sink may already be closed by Helidon if the client disconnected 269 } 270 } 271 } 272 273 private void resultHandler(ServerRequest req, ServerResponse resp) { 274 var sessionOpt = findSession(req, resp); 275 if (sessionOpt.isEmpty()) { 276 return; 277 } 278 var session = sessionOpt.orElseThrow(); 279 var timeoutSeconds = parseResultTimeoutSeconds(req.query().first("timeout").orElse(null)); 280 var outcome = awaitResult(session.result(), timeoutSeconds, session.sessionId()); 281 if (outcome.body() == null) { 282 resp.status(outcome.status()).send(); 283 } else { 284 resp.status(outcome.status()).send(outcome.body()); 285 } 286 } 287 288 /** 289 * Outcome of a long-poll wait on a session's terminal future, captured as an HTTP {@link Status} 290 * + optional body. {@code null} body produces a body-less response (used for the {@code 204 No 291 * Content} timeout case). 292 */ 293 record ResultLongPollOutcome(Status status, Object body) {} 294 295 /** 296 * Wait up to {@code timeoutSeconds} for {@code future} to complete and translate the result into 297 * an HTTP status + body. Package-private so unit tests can exercise the catch paths ({@link 298 * InterruptedException} / {@link ExecutionException}) that are awkward to reach from a black-box 299 * HTTP test. 300 * 301 * @param future the session's result future; non-null 302 * @param timeoutSeconds non-negative wait budget 303 * @param sessionIdForLog session id used only for the WARNING log on execution failure 304 * @return outcome to translate to the HTTP response 305 */ 306 static ResultLongPollOutcome awaitResult( 307 CompletableFuture<ResultMessage> future, long timeoutSeconds, String sessionIdForLog) { 308 try { 309 var terminal = future.get(timeoutSeconds, TimeUnit.SECONDS); 310 return new ResultLongPollOutcome( 311 Status.OK_200, Map.of("type", terminal.getClass().getSimpleName(), "result", terminal)); 312 } catch (TimeoutException e) { 313 return new ResultLongPollOutcome(Status.NO_CONTENT_204, null); 314 } catch (InterruptedException e) { 315 Thread.currentThread().interrupt(); 316 return new ResultLongPollOutcome( 317 Status.SERVICE_UNAVAILABLE_503, 318 Map.of("error", "request interrupted while waiting for session result")); 319 } catch (ExecutionException e) { 320 LOGGER.log( 321 Level.WARNING, "session " + sessionIdForLog + " result future failed exceptionally", e); 322 var cause = e.getCause(); 323 var msg = cause == null || cause.getMessage() == null ? "unknown" : cause.getMessage(); 324 return new ResultLongPollOutcome( 325 Status.INTERNAL_SERVER_ERROR_500, 326 Map.of("error", "session terminated abnormally: " + msg)); 327 } 328 } 329 330 /** Default long-poll timeout when the client omits {@code ?timeout}. */ 331 static final long DEFAULT_RESULT_TIMEOUT_SECONDS = 60L; 332 333 /** Hard cap on the long-poll timeout so one request cannot pin a server thread indefinitely. */ 334 static final long MAX_RESULT_TIMEOUT_SECONDS = 300L; 335 336 /** 337 * Parse the {@code timeout} query parameter. {@code null} or blank → {@link 338 * #DEFAULT_RESULT_TIMEOUT_SECONDS}; malformed values silently fall back to the default rather 339 * than 400ing (long-poll clients sometimes omit or mistype the param). Negative values clamp to 340 * {@code 0}; values above {@link #MAX_RESULT_TIMEOUT_SECONDS} clamp to the cap. 341 * 342 * <p>Package-private for unit-test access; the HTTP handler reads the query param and passes the 343 * raw value here. 344 * 345 * @param raw the raw query-string value; may be {@code null} 346 * @return a long in {@code [0, MAX_RESULT_TIMEOUT_SECONDS]} 347 */ 348 static long parseResultTimeoutSeconds(String raw) { 349 if (Strings.isBlank(raw)) { 350 return DEFAULT_RESULT_TIMEOUT_SECONDS; 351 } 352 long n; 353 try { 354 n = Long.parseLong(raw.trim()); 355 } catch (NumberFormatException e) { 356 return DEFAULT_RESULT_TIMEOUT_SECONDS; 357 } 358 if (n < 0L) { 359 return 0L; 360 } 361 if (n > MAX_RESULT_TIMEOUT_SECONDS) { 362 return MAX_RESULT_TIMEOUT_SECONDS; 363 } 364 return n; 365 } 366 367 private void deleteHandler(ServerRequest req, ServerResponse resp) { 368 var sessionId = req.path().pathParameters().get("sessionId"); 369 var removed = registry.close(sessionId); 370 if (!removed) { 371 resp.status(Status.NOT_FOUND_404).send(Map.of("error", "session not found")); 372 return; 373 } 374 resp.status(Status.NO_CONTENT_204).send(); 375 } 376 377 // ── helpers ───────────────────────────────────────────────────────────── 378 379 private Optional<AgentSession> findSession(ServerRequest req, ServerResponse resp) { 380 var sessionId = req.path().pathParameters().get("sessionId"); 381 var sessionOpt = registry.get(sessionId); 382 if (sessionOpt.isEmpty()) { 383 resp.status(Status.NOT_FOUND_404).send(Map.of("error", "session not found")); 384 } 385 return sessionOpt; 386 } 387 388 @SuppressWarnings("unchecked") 389 private Map<String, Object> readJsonBody(ServerRequest req) { 390 try { 391 return (Map<String, Object>) req.content().as(Map.class); 392 } catch (RuntimeException e) { 393 throw new JacksonRuntimeException("failed to parse request body", e); 394 } 395 } 396 397 private static String eventName(QueryEvent event) { 398 return event.getClass().getSimpleName(); 399 } 400 401 /** 402 * Decide whether {@code ex} (or any of its causes) represents a client disconnect during SSE 403 * emit. The agent loop should keep producing events for in-flight work even when the HTTP peer 404 * has gone away; the only effect is that we stop forwarding to the dead sink. 405 * 406 * <p>Helidon 4.x raises {@link CloseConnectionException} (and its subclass {@code 407 * ServerConnectionException}) when it detects the peer closed the socket — that is the 408 * authoritative typed signal and the first check below. 409 * 410 * <p>String-matching on {@link SocketException} / {@link IOException} messages is preserved as a 411 * fallback for code paths that bypass Helidon's wrapping (raw socket I/O surfacing through the 412 * JDK), and for forward-compatibility if a future Helidon version stops wrapping in some 413 * scenarios. Matches the three messages the JDK socket layer produces on local-peer hangups 414 * across platforms. 415 */ 416 static boolean isDisconnect(Throwable ex) { 417 var current = ex; 418 while (current != null) { 419 if (current instanceof CloseConnectionException) { 420 return true; 421 } 422 if (current instanceof SocketException || current instanceof IOException) { 423 var msg = current.getMessage(); 424 if (msg != null 425 && (msg.contains("Broken pipe") 426 || msg.contains("Connection reset") 427 || msg.contains("Socket closed"))) { 428 return true; 429 } 430 } 431 current = current.getCause(); 432 } 433 return false; 434 } 435}