TerminalSse.vala 3.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495
  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. public class TerminalSseEndpoint : SseEndpoint {
  22. private OrmSession db = inject<OrmSession>();
  23. /** Reconnect hint for browsers (EventSource retries sooner). */
  24. public override uint retry_interval { get { return 3000; } }
  25. public override async void new_connection(HttpContext http_context,
  26. RouteContext route_context, SseStream stream) {
  27. string id_text = "";
  28. route_context.mapped_parameters.try_get("session_id", out id_text);
  29. int64 session_id = int64.parse(id_text);
  30. int64 cursor = 0;
  31. try {
  32. while (!stream.is_closed) {
  33. bool pushed = false;
  34. foreach (var entry in yield db.query<SessionCommand>()
  35. .where(expr("session_id == $0 && id > $1",
  36. elem<int64?>(session_id), elem<int64?>(cursor)))
  37. .order_by(expr("id"))
  38. .materialise_async()) {
  39. yield stream.send_event(new SseEvent.with_id(
  40. entry.id.to_string(), payload(entry)));
  41. cursor = entry.id;
  42. pushed = true;
  43. }
  44. if (!pushed) {
  45. // Heartbeat: detects disconnected clients even when
  46. // the session is quiet.
  47. yield stream.send_event(new SseEvent("ping"));
  48. }
  49. yield nap_seconds(1);
  50. }
  51. } catch (Error e) {
  52. // A closed stream ends the loop; anything else is logged.
  53. if (!stream.is_closed) {
  54. warning(@"[ClankerSaddle] Terminal stream for session $(session_id) ended: $(e.message)");
  55. }
  56. }
  57. }
  58. /** The entry as a one-line JSON object payload. */
  59. private static string payload(SessionCommand entry) {
  60. var builder = new Json.Builder();
  61. builder.begin_object();
  62. builder.set_member_name("id");
  63. builder.add_int_value(entry.id);
  64. builder.set_member_name("command");
  65. builder.add_string_value(entry.command_text);
  66. builder.set_member_name("output");
  67. builder.add_string_value(entry.output_text ?? "");
  68. builder.set_member_name("type");
  69. builder.add_string_value(entry.entry_type);
  70. if (entry.piped_input != null && ((!)entry.piped_input).length > 0) {
  71. builder.set_member_name("input");
  72. builder.add_string_value(((!)entry.piped_input));
  73. }
  74. builder.end_object();
  75. var generator = new Json.Generator();
  76. generator.set_root(builder.get_root());
  77. return generator.to_data(null);
  78. }
  79. private static async void nap_seconds(uint seconds) {
  80. GLib.Timeout.add_seconds(seconds, () => {
  81. nap_seconds.callback();
  82. return false;
  83. });
  84. yield;
  85. }
  86. }
  87. }