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 } 216 217 @Override 218 public void onNext(QueryEvent event) { 219 try { 220 sink.emit( 221 SseEvent.builder() 222 .name(eventName(event)) 223 .data(objectMapper.writeValueAsString(event)) 224 .build()); 225 } catch (Exception ex) { 226 if (!isDisconnect(ex)) { 227 LOGGER.log( 228 Level.WARNING, "SSE emit failed for session " + session.sessionId(), ex); 229 } 230 var s = subscription.get(); 231 if (s != null) { 232 s.cancel(); 233 } 234 done.countDown(); 235 } 236 } 237 238 @Override 239 public void onError(Throwable t) { 240 LOGGER.log( 241 Level.WARNING, 242 "events publisher errored for session " + session.sessionId(), 243 t); 244 done.countDown(); 245 } 246 247 @Override 248 public void onComplete() { 249 done.countDown(); 250 } 251 }); 252 try { 253 done.await(); 254 } catch (InterruptedException e) { 255 Thread.currentThread().interrupt(); 256 } finally { 257 try { 258 sink.close(); 259 } catch (Exception ignored) { 260 // sink may already be closed by Helidon if the client disconnected 261 } 262 } 263 } 264 265 private void resultHandler(ServerRequest req, ServerResponse resp) { 266 var sessionOpt = findSession(req, resp); 267 if (sessionOpt.isEmpty()) { 268 return; 269 } 270 var session = sessionOpt.orElseThrow(); 271 var timeoutSeconds = parseResultTimeoutSeconds(req.query().first("timeout").orElse(null)); 272 var outcome = awaitResult(session.result(), timeoutSeconds, session.sessionId()); 273 if (outcome.body() == null) { 274 resp.status(outcome.status()).send(); 275 } else { 276 resp.status(outcome.status()).send(outcome.body()); 277 } 278 } 279 280 /** 281 * Outcome of a long-poll wait on a session's terminal future, captured as an HTTP {@link Status} 282 * + optional body. {@code null} body produces a body-less response (used for the {@code 204 No 283 * Content} timeout case). 284 */ 285 record ResultLongPollOutcome(Status status, Object body) {} 286 287 /** 288 * Wait up to {@code timeoutSeconds} for {@code future} to complete and translate the result into 289 * an HTTP status + body. Package-private so unit tests can exercise the catch paths ({@link 290 * InterruptedException} / {@link ExecutionException}) that are awkward to reach from a black-box 291 * HTTP test. 292 * 293 * @param future the session's result future; non-null 294 * @param timeoutSeconds non-negative wait budget 295 * @param sessionIdForLog session id used only for the WARNING log on execution failure 296 * @return outcome to translate to the HTTP response 297 */ 298 static ResultLongPollOutcome awaitResult( 299 CompletableFuture<ResultMessage> future, long timeoutSeconds, String sessionIdForLog) { 300 try { 301 var terminal = future.get(timeoutSeconds, TimeUnit.SECONDS); 302 return new ResultLongPollOutcome( 303 Status.OK_200, Map.of("type", terminal.getClass().getSimpleName(), "result", terminal)); 304 } catch (TimeoutException e) { 305 return new ResultLongPollOutcome(Status.NO_CONTENT_204, null); 306 } catch (InterruptedException e) { 307 Thread.currentThread().interrupt(); 308 return new ResultLongPollOutcome( 309 Status.SERVICE_UNAVAILABLE_503, 310 Map.of("error", "request interrupted while waiting for session result")); 311 } catch (ExecutionException e) { 312 LOGGER.log( 313 Level.WARNING, "session " + sessionIdForLog + " result future failed exceptionally", e); 314 var cause = e.getCause(); 315 var msg = cause == null || cause.getMessage() == null ? "unknown" : cause.getMessage(); 316 return new ResultLongPollOutcome( 317 Status.INTERNAL_SERVER_ERROR_500, 318 Map.of("error", "session terminated abnormally: " + msg)); 319 } 320 } 321 322 /** Default long-poll timeout when the client omits {@code ?timeout}. */ 323 static final long DEFAULT_RESULT_TIMEOUT_SECONDS = 60L; 324 325 /** Hard cap on the long-poll timeout so one request cannot pin a server thread indefinitely. */ 326 static final long MAX_RESULT_TIMEOUT_SECONDS = 300L; 327 328 /** 329 * Parse the {@code timeout} query parameter. {@code null} or blank → {@link 330 * #DEFAULT_RESULT_TIMEOUT_SECONDS}; malformed values silently fall back to the default rather 331 * than 400ing (long-poll clients sometimes omit or mistype the param). Negative values clamp to 332 * {@code 0}; values above {@link #MAX_RESULT_TIMEOUT_SECONDS} clamp to the cap. 333 * 334 * <p>Package-private for unit-test access; the HTTP handler reads the query param and passes the 335 * raw value here. 336 * 337 * @param raw the raw query-string value; may be {@code null} 338 * @return a long in {@code [0, MAX_RESULT_TIMEOUT_SECONDS]} 339 */ 340 static long parseResultTimeoutSeconds(String raw) { 341 if (Strings.isBlank(raw)) { 342 return DEFAULT_RESULT_TIMEOUT_SECONDS; 343 } 344 long n; 345 try { 346 n = Long.parseLong(raw.trim()); 347 } catch (NumberFormatException e) { 348 return DEFAULT_RESULT_TIMEOUT_SECONDS; 349 } 350 if (n < 0L) { 351 return 0L; 352 } 353 if (n > MAX_RESULT_TIMEOUT_SECONDS) { 354 return MAX_RESULT_TIMEOUT_SECONDS; 355 } 356 return n; 357 } 358 359 private void deleteHandler(ServerRequest req, ServerResponse resp) { 360 var sessionId = req.path().pathParameters().get("sessionId"); 361 var removed = registry.close(sessionId); 362 if (!removed) { 363 resp.status(Status.NOT_FOUND_404).send(Map.of("error", "session not found")); 364 return; 365 } 366 resp.status(Status.NO_CONTENT_204).send(); 367 } 368 369 // ── helpers ───────────────────────────────────────────────────────────── 370 371 private Optional<AgentSession> findSession(ServerRequest req, ServerResponse resp) { 372 var sessionId = req.path().pathParameters().get("sessionId"); 373 var sessionOpt = registry.get(sessionId); 374 if (sessionOpt.isEmpty()) { 375 resp.status(Status.NOT_FOUND_404).send(Map.of("error", "session not found")); 376 } 377 return sessionOpt; 378 } 379 380 @SuppressWarnings("unchecked") 381 private Map<String, Object> readJsonBody(ServerRequest req) { 382 try { 383 return (Map<String, Object>) req.content().as(Map.class); 384 } catch (RuntimeException e) { 385 throw new JacksonRuntimeException("failed to parse request body", e); 386 } 387 } 388 389 private static String eventName(QueryEvent event) { 390 return event.getClass().getSimpleName(); 391 } 392 393 /** 394 * Decide whether {@code ex} (or any of its causes) represents a client disconnect during SSE 395 * emit. The agent loop should keep producing events for in-flight work even when the HTTP peer 396 * has gone away; the only effect is that we stop forwarding to the dead sink. 397 * 398 * <p>Helidon 4.x raises {@link CloseConnectionException} (and its subclass {@code 399 * ServerConnectionException}) when it detects the peer closed the socket — that is the 400 * authoritative typed signal and the first check below. 401 * 402 * <p>String-matching on {@link SocketException} / {@link IOException} messages is preserved as a 403 * fallback for code paths that bypass Helidon's wrapping (raw socket I/O surfacing through the 404 * JDK), and for forward-compatibility if a future Helidon version stops wrapping in some 405 * scenarios. Matches the three messages the JDK socket layer produces on local-peer hangups 406 * across platforms. 407 */ 408 static boolean isDisconnect(Throwable ex) { 409 var current = ex; 410 while (current != null) { 411 if (current instanceof CloseConnectionException) { 412 return true; 413 } 414 if (current instanceof SocketException || current instanceof IOException) { 415 var msg = current.getMessage(); 416 if (msg != null 417 && (msg.contains("Broken pipe") 418 || msg.contains("Connection reset") 419 || msg.contains("Socket closed"))) { 420 return true; 421 } 422 } 423 current = current.getCause(); 424 } 425 return false; 426 } 427}