TerminalSse.vala 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. using Invercargill.Expressions;
  2. using InvercargillSql.Orm;
  3. using Inversion;
  4. using Astralis;
  5. using ClankerSaddle.Data;
  6. namespace ClankerSaddle.Api {
  7. /**
  8. * `GET /terminal-stream/{session_id}` — a Server-Sent Events stream of
  9. * one session's terminal log. Every existing `session_commands` row is
  10. * pushed as an initial event; the connection then stays open, polling
  11. * the database each second and streaming entries newer than the id
  12. * cursor until the client disconnects.
  13. *
  14. * Each event carries `id: <entry id>` (so EventSource reconnects
  15. * resume from the last seen entry) and a `data` payload of
  16. * `{"id", "command", "output", "type", "input"?}` — the entry types
  17. * the terminal page renders with per-type styling (command, comment,
  18. * block, user-comment, progress, review, plan, shell-reset, failed,
  19. * query).
  20. *
  21. * Sessions with raw PTY rows (`session_raw_log`, the PTY-terminal
  22. * feature) stream those instead: `{"seq", "type": "raw", "text"}`
  23. * events in seq order, polled the same way. The client renders them
  24. * as one unstyled `<pre>` of concatenated chunks.
  25. */
  26. public class TerminalSseEndpoint : SseEndpoint {
  27. private OrmSession db = inject<OrmSession>();
  28. /** Reconnect hint for browsers (EventSource retries sooner). */
  29. public override uint retry_interval { get { return 3000; } }
  30. public override async void new_connection(HttpContext http_context,
  31. RouteContext route_context, SseStream stream) {
  32. string id_text = "";
  33. route_context.mapped_parameters.try_get("session_id", out id_text);
  34. int64 session_id = int64.parse(id_text);
  35. bool has_raw = false;
  36. try {
  37. var raw_probe = yield db.query<SessionRawLog>()
  38. .where(expr("session_id == $0", elem<int64?>(session_id)))
  39. .first_async();
  40. has_raw = raw_probe != null;
  41. } catch (Error e) {
  42. warning(@"[ClankerSaddle] Terminal stream probe for session $(session_id) failed: $(e.message)");
  43. return;
  44. }
  45. if (has_raw) {
  46. yield stream_raw_log(session_id, stream);
  47. return;
  48. }
  49. int64 cursor = 0;
  50. try {
  51. while (!stream.is_closed) {
  52. bool pushed = false;
  53. foreach (var entry in yield db.query<SessionCommand>()
  54. .where(expr("session_id == $0 && id > $1",
  55. elem<int64?>(session_id), elem<int64?>(cursor)))
  56. .order_by(expr("id"))
  57. .materialise_async()) {
  58. yield stream.send_event(new SseEvent.with_id(
  59. entry.id.to_string(), payload(entry)));
  60. cursor = entry.id;
  61. pushed = true;
  62. }
  63. if (!pushed) {
  64. // Heartbeat: detects disconnected clients even when
  65. // the session is quiet.
  66. yield stream.send_event(new SseEvent("ping"));
  67. }
  68. yield nap_seconds(1);
  69. }
  70. } catch (Error e) {
  71. // A closed stream ends the loop; anything else is logged.
  72. if (!stream.is_closed) {
  73. warning(@"[ClankerSaddle] Terminal stream for session $(session_id) ended: $(e.message)");
  74. }
  75. }
  76. }
  77. /** The raw-log variant of the stream loop above. */
  78. private async void stream_raw_log(int64 session_id, SseStream stream) {
  79. int64 cursor = 0;
  80. try {
  81. while (!stream.is_closed) {
  82. bool pushed = false;
  83. foreach (var chunk in yield db.query<SessionRawLog>()
  84. .where(expr("session_id == $0 && seq > $1",
  85. elem<int64?>(session_id), elem<int64?>(cursor)))
  86. .order_by(expr("seq"))
  87. .materialise_async()) {
  88. yield stream.send_event(new SseEvent.with_id(
  89. chunk.seq.to_string(), raw_payload(chunk)));
  90. cursor = chunk.seq;
  91. pushed = true;
  92. }
  93. if (!pushed) {
  94. yield stream.send_event(new SseEvent("ping"));
  95. }
  96. yield nap_seconds(1);
  97. }
  98. } catch (Error e) {
  99. if (!stream.is_closed) {
  100. warning(@"[ClankerSaddle] Raw terminal stream for session $(session_id) ended: $(e.message)");
  101. }
  102. }
  103. }
  104. /** The entry as a one-line JSON object payload. */
  105. private static string payload(SessionCommand entry) {
  106. var builder = new Json.Builder();
  107. builder.begin_object();
  108. builder.set_member_name("id");
  109. builder.add_int_value(entry.id);
  110. builder.set_member_name("command");
  111. builder.add_string_value(entry.command_text);
  112. builder.set_member_name("output");
  113. builder.add_string_value(entry.output_text ?? "");
  114. builder.set_member_name("type");
  115. builder.add_string_value(entry.entry_type);
  116. if (entry.piped_input != null && ((!)entry.piped_input).length > 0) {
  117. builder.set_member_name("input");
  118. builder.add_string_value(((!)entry.piped_input));
  119. }
  120. builder.end_object();
  121. var generator = new Json.Generator();
  122. generator.set_root(builder.get_root());
  123. return generator.to_data(null);
  124. }
  125. /** A raw chunk as a one-line JSON object payload. */
  126. private static string raw_payload(SessionRawLog chunk) {
  127. var builder = new Json.Builder();
  128. builder.begin_object();
  129. builder.set_member_name("seq");
  130. builder.add_int_value(chunk.seq);
  131. builder.set_member_name("type");
  132. builder.add_string_value("raw");
  133. builder.set_member_name("text");
  134. builder.add_string_value(chunk.chunk);
  135. builder.end_object();
  136. var generator = new Json.Generator();
  137. generator.set_root(builder.get_root());
  138. return generator.to_data(null);
  139. }
  140. private static async void nap_seconds(uint seconds) {
  141. GLib.Timeout.add_seconds(seconds, () => {
  142. nap_seconds.callback();
  143. return false;
  144. });
  145. yield;
  146. }
  147. }
  148. }