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      // Helios is a library; diagnostic info reaches the deployer, who decides whether to
321      // forward it to their downstream HTTP clients. The cause message is part of that
322      // diagnostic surface — stripping it here would force deployers to dig through server-side
323      // logs to correlate a failed request with what actually went wrong. Deployers that need to
324      // sanitise the body can wrap this response at their HTTP layer.
325      LOGGER.log(
326          Level.WARNING, "session " + sessionIdForLog + " result future failed exceptionally", e);
327      var cause = e.getCause();
328      var msg = cause == null || cause.getMessage() == null ? "unknown" : cause.getMessage();
329      return new ResultLongPollOutcome(
330          Status.INTERNAL_SERVER_ERROR_500,
331          Map.of("error", "session terminated abnormally: " + msg));
332    }
333  }
334
335  /** Default long-poll timeout when the client omits {@code ?timeout}. */
336  static final long DEFAULT_RESULT_TIMEOUT_SECONDS = 60L;
337
338  /** Hard cap on the long-poll timeout so one request cannot pin a server thread indefinitely. */
339  static final long MAX_RESULT_TIMEOUT_SECONDS = 300L;
340
341  /**
342   * Parse the {@code timeout} query parameter. {@code null} or blank → {@link
343   * #DEFAULT_RESULT_TIMEOUT_SECONDS}; malformed values silently fall back to the default rather
344   * than 400ing (long-poll clients sometimes omit or mistype the param). Negative values clamp to
345   * {@code 0}; values above {@link #MAX_RESULT_TIMEOUT_SECONDS} clamp to the cap.
346   *
347   * <p>Package-private for unit-test access; the HTTP handler reads the query param and passes the
348   * raw value here.
349   *
350   * @param raw the raw query-string value; may be {@code null}
351   * @return a long in {@code [0, MAX_RESULT_TIMEOUT_SECONDS]}
352   */
353  static long parseResultTimeoutSeconds(String raw) {
354    if (Strings.isBlank(raw)) {
355      return DEFAULT_RESULT_TIMEOUT_SECONDS;
356    }
357    long n;
358    try {
359      n = Long.parseLong(raw.trim());
360    } catch (NumberFormatException e) {
361      return DEFAULT_RESULT_TIMEOUT_SECONDS;
362    }
363    if (n < 0L) {
364      return 0L;
365    }
366    if (n > MAX_RESULT_TIMEOUT_SECONDS) {
367      return MAX_RESULT_TIMEOUT_SECONDS;
368    }
369    return n;
370  }
371
372  private void deleteHandler(ServerRequest req, ServerResponse resp) {
373    var sessionId = req.path().pathParameters().get("sessionId");
374    var removed = registry.close(sessionId);
375    if (!removed) {
376      resp.status(Status.NOT_FOUND_404).send(Map.of("error", "session not found"));
377      return;
378    }
379    resp.status(Status.NO_CONTENT_204).send();
380  }
381
382  // ── helpers ─────────────────────────────────────────────────────────────
383
384  private Optional<AgentSession> findSession(ServerRequest req, ServerResponse resp) {
385    var sessionId = req.path().pathParameters().get("sessionId");
386    var sessionOpt = registry.get(sessionId);
387    if (sessionOpt.isEmpty()) {
388      resp.status(Status.NOT_FOUND_404).send(Map.of("error", "session not found"));
389    }
390    return sessionOpt;
391  }
392
393  @SuppressWarnings("unchecked")
394  private Map<String, Object> readJsonBody(ServerRequest req) {
395    try {
396      return (Map<String, Object>) req.content().as(Map.class);
397    } catch (RuntimeException e) {
398      throw new JacksonRuntimeException("failed to parse request body", e);
399    }
400  }
401
402  private static String eventName(QueryEvent event) {
403    return event.getClass().getSimpleName();
404  }
405
406  /**
407   * Decide whether {@code ex} (or any of its causes) represents a client disconnect during SSE
408   * emit. The agent loop should keep producing events for in-flight work even when the HTTP peer
409   * has gone away; the only effect is that we stop forwarding to the dead sink.
410   *
411   * <p>Helidon 4.x raises {@link CloseConnectionException} (and its subclass {@code
412   * ServerConnectionException}) when it detects the peer closed the socket — that is the
413   * authoritative typed signal and the first check below.
414   *
415   * <p>String-matching on {@link SocketException} / {@link IOException} messages is preserved as a
416   * fallback for code paths that bypass Helidon's wrapping (raw socket I/O surfacing through the
417   * JDK), and for forward-compatibility if a future Helidon version stops wrapping in some
418   * scenarios. Matches the three messages the JDK socket layer produces on local-peer hangups
419   * across platforms.
420   */
421  static boolean isDisconnect(Throwable ex) {
422    var current = ex;
423    while (current != null) {
424      if (current instanceof CloseConnectionException) {
425        return true;
426      }
427      if (current instanceof SocketException || current instanceof IOException) {
428        var msg = current.getMessage();
429        if (msg != null
430            && (msg.contains("Broken pipe")
431                || msg.contains("Connection reset")
432                || msg.contains("Socket closed"))) {
433          return true;
434        }
435      }
436      current = current.getCause();
437    }
438    return false;
439  }
440}