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}