| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495 |
- using Invercargill.Expressions;
- using InvercargillSql.Orm;
- using Inversion;
- using Astralis;
- using ClankerSaddle.Data;
- namespace ClankerSaddle.Api {
- /**
- * `GET /terminal-stream/{session_id}` — a Server-Sent Events stream of
- * one session's terminal log. Every existing `session_commands` row is
- * pushed as an initial event; the connection then stays open, polling
- * the database each second and streaming entries newer than the id
- * cursor until the client disconnects.
- *
- * Each event carries `id: <entry id>` (so EventSource reconnects
- * resume from the last seen entry) and a `data` payload of
- * `{"id", "command", "output", "type", "input"?}` — the entry types
- * the terminal page renders with per-type styling (command, comment,
- * block, user-comment, progress, review, plan, shell-reset, failed,
- * query).
- */
- public class TerminalSseEndpoint : SseEndpoint {
- private OrmSession db = inject<OrmSession>();
- /** Reconnect hint for browsers (EventSource retries sooner). */
- public override uint retry_interval { get { return 3000; } }
- public override async void new_connection(HttpContext http_context,
- RouteContext route_context, SseStream stream) {
- string id_text = "";
- route_context.mapped_parameters.try_get("session_id", out id_text);
- int64 session_id = int64.parse(id_text);
- int64 cursor = 0;
- try {
- while (!stream.is_closed) {
- bool pushed = false;
- foreach (var entry in yield db.query<SessionCommand>()
- .where(expr("session_id == $0 && id > $1",
- elem<int64?>(session_id), elem<int64?>(cursor)))
- .order_by(expr("id"))
- .materialise_async()) {
- yield stream.send_event(new SseEvent.with_id(
- entry.id.to_string(), payload(entry)));
- cursor = entry.id;
- pushed = true;
- }
- if (!pushed) {
- // Heartbeat: detects disconnected clients even when
- // the session is quiet.
- yield stream.send_event(new SseEvent("ping"));
- }
- yield nap_seconds(1);
- }
- } catch (Error e) {
- // A closed stream ends the loop; anything else is logged.
- if (!stream.is_closed) {
- warning(@"[ClankerSaddle] Terminal stream for session $(session_id) ended: $(e.message)");
- }
- }
- }
- /** The entry as a one-line JSON object payload. */
- private static string payload(SessionCommand entry) {
- var builder = new Json.Builder();
- builder.begin_object();
- builder.set_member_name("id");
- builder.add_int_value(entry.id);
- builder.set_member_name("command");
- builder.add_string_value(entry.command_text);
- builder.set_member_name("output");
- builder.add_string_value(entry.output_text ?? "");
- builder.set_member_name("type");
- builder.add_string_value(entry.entry_type);
- if (entry.piped_input != null && ((!)entry.piped_input).length > 0) {
- builder.set_member_name("input");
- builder.add_string_value(((!)entry.piped_input));
- }
- builder.end_object();
- var generator = new Json.Generator();
- generator.set_root(builder.get_root());
- return generator.to_data(null);
- }
- private static async void nap_seconds(uint seconds) {
- GLib.Timeout.add_seconds(seconds, () => {
- nap_seconds.callback();
- return false;
- });
- yield;
- }
- }
- }
|