Files
2026-02-17 17:30:45 -05:00

9.1 KiB

PRD: TUI Session Manager

Module: tui-sm/ | Namespace: ajet.chat.tui-sm.* Status: v1 | Last updated: 2026-02-17


1. Overview

The TUI Session Manager serves terminal clients via SSE. It holds live connections from TUI clients, subscribes to NATS for real-time events, and calls the API for data. It acts as an intermediary — translating NATS events into structured SSE events that the TUI client renders.

Unlike Web SM (which sends HTML fragments), TUI SM sends JSON-structured events over SSE. The TUI client handles all rendering.

2. Architecture

TUI Client ←─ SSE (JSON events) ──→ TUI SM ──→ NATS (subscribe)
           ←─ HTTP POST (signals) ──→       ──→ API (HTTP, data)

3. SSE Event Protocol

3.1 Connection

Endpoint: GET /tui/sse/events Headers:

  • Accept: text/event-stream
  • X-User-Id, X-Trace-Id (injected by Auth GW)

Query params:

  • community_id=<uuid> — active community (optional, can start with DMs)
  • last_event_id=<id> — for reconnection replay

3.2 SSE Event Format

event: <event-type>
id: <sequential-id>
data: <json-payload>

3.3 Event Types

Event Type Payload Description
init {communities, channels, dms, user} Initial state on connect
message.new {message} New message in subscribed channel
message.edit {message} Message edited
message.delete {message_id, channel_id} Message deleted
reaction.add {message_id, emoji, user_id} Reaction added
reaction.remove {message_id, emoji, user_id} Reaction removed
typing.start {channel_id, user_id, username} User started typing
typing.stop {channel_id, user_id} User stopped typing
presence.update {user_id, status} Online/offline change
channel.new {channel} New channel in community
channel.update {channel} Channel updated (topic, name)
channel.delete {channel_id} Channel deleted
member.join {channel_id, user} User joined channel
member.leave {channel_id, user_id} User left channel
notification {notification} New notification for user
unread.update {channel_id, count, mentions} Unread count changed
ping {} Keepalive (every 30s)

3.4 Init Event

Sent immediately after SSE connection established:

{
  "communities": [
    {"id": "uuid", "name": "My Team", "slug": "my-team", "role": "owner"}
  ],
  "channels": {
    "community-uuid": [
      {"id": "uuid", "name": "general", "type": "text", "category": "General",
       "unread_count": 3, "mention_count": 1}
    ]
  },
  "dms": [
    {"id": "uuid", "type": "dm", "members": [...], "unread_count": 0}
  ],
  "user": {"id": "uuid", "username": "alice", "display_name": "Alice"}
}

4. Client → Server Signals (HTTP POST)

Action Endpoint Payload
Send message POST /tui/messages {channel_id, body_md, parent_id?}
Edit message POST /tui/messages/:id/edit {body_md}
Delete message POST /tui/messages/:id/delete
Add reaction POST /tui/reactions {message_id, emoji}
Remove reaction POST /tui/reactions/remove {message_id, emoji}
Switch channel POST /tui/navigate {channel_id, community_id?}
Mark read POST /tui/read {channel_id, message_id}
Typing POST /tui/typing {channel_id}
Heartbeat POST /tui/heartbeat
Fetch messages GET /tui/messages ?channel_id=<uuid>&before=<uuid>&limit=50
Search GET /tui/search ?q=<query>&type=<type>
Slash command POST /tui/command {command, channel_id, community_id}

All responses are JSON.

5. Connection Tracking

{user-id {:sse-channel       <http-kit-channel>
          :active-community  uuid-or-nil
          :active-channel    uuid-or-nil
          :nats-subs         [sub-handles...]
          :last-event-id     int
          :connected-at      instant}}
  • On connect: send init event, subscribe to user's NATS subjects
  • On disconnect: unsubscribe from NATS, clean up state
  • On community switch: update NATS subscriptions
  • Keepalive pings every 30 seconds to detect dead connections

6. Presence Batching

TUI SM buffers presence events to avoid flooding clients:

  • Collect heartbeat events over 60-second windows
  • On flush: diff with previous state, send only changes
  • Typing indicators sent immediately (latency-sensitive)

7. Service Configuration

7.1 Config Shape

{:server {:host "0.0.0.0" :port 3003}
 :api    {:base-url "http://localhost:3001"}
 :nats   {:url "nats://localhost:4222"
          :stream-name "ajet-events"}
 :session {:max-connections 5000          ;; max concurrent SSE connections
           :ping-interval-sec 30}
 :presence {:batch-interval-sec 60}}

7.2 Startup / Shutdown Sequence

Startup:

1. Load config
2. Connect to NATS
3. Initialize connection tracker (atom)
4. Start http-kit server
5. Start ping scheduler (30s interval)
6. Log "TUI SM started on port {port}"

Shutdown (graceful):

1. Stop accepting new SSE connections
2. Send close event to all connected TUI clients
3. Stop ping scheduler
4. Unsubscribe all NATS subscriptions
5. Close NATS connection
6. Stop http-kit server
7. Log "TUI SM stopped"

7.3 Health Check

Method Path Auth Description
GET /tui/health None Service health
{"status": "ok", "connections": 23, "checks": {"api": "ok", "nats": "ok"}}

7.4 Error Handling

Scenario Behavior
API unavailable Client signals (POST) return 502 JSON error. SSE stays open.
NATS unavailable SSE stays open, no real-time events. Events resume when NATS reconnects. Status event sent to client.
Client sends invalid JSON Return 400 with error description
Client sends to unauthorized channel Return 403
SSE write fails (dead connection) Clean up connection state, unsubscribe NATS
Max connections reached Return 503 {"error": "max connections reached, retry later"}

7.5 Backpressure

  • If a client's SSE buffer exceeds 1000 events, disconnect the client (forces reconnect with replay)
  • Events are dropped (not queued) if write buffer is full — JetStream replay handles recovery
  • Connection tracking atom is bounded by max-connections config

8. Test Cases

8.1 SSE Connection

ID Test Description
TSM-T1 SSE connect GET /tui/sse/events returns text/event-stream
TSM-T2 Init event sent First event is init with communities, channels, DMs, user
TSM-T3 NATS subscribed on connect Server subscribes to user's community subjects
TSM-T4 Ping keepalive Ping event sent every 30 seconds
TSM-T5 Disconnect cleanup SSE disconnect unsubscribes from NATS
TSM-T6 Reconnect with last_event_id Missed events replayed via JetStream

8.2 Real-Time Events

ID Test Description
TSM-T7 New message event Message from NATS → SSE message.new event to client
TSM-T8 Message edit event Edit from NATS → SSE message.edit event
TSM-T9 Message delete event Delete from NATS → SSE message.delete event
TSM-T10 Reaction events Add/remove from NATS → SSE reaction events
TSM-T11 Typing indicator Typing from NATS → SSE typing.start event
TSM-T12 Typing auto-expire typing.stop sent after 15 seconds of silence
TSM-T13 Presence update Batched presence diffs sent every 60 seconds
TSM-T14 Channel events New/update/delete channel events forwarded
TSM-T15 Notification event User-targeted notification forwarded
TSM-T16 Unread count update New message in other channel → unread count SSE event

8.3 Client Signals

ID Test Description
TSM-T17 Send message POST /tui/messages → API call → success response
TSM-T18 Edit message POST /tui/messages/:id/edit → API call → success
TSM-T19 Delete message POST /tui/messages/:id/delete → API call → success
TSM-T20 Switch channel POST /tui/navigate → NATS sub update → returns channel messages
TSM-T21 Fetch older messages GET /tui/messages with before cursor → paginated results
TSM-T22 Search GET /tui/search → API search → JSON results
TSM-T23 Slash command POST /tui/command → API command → result
TSM-T24 Heartbeat POST /tui/heartbeat → proxied to API

8.4 Edge Cases

ID Test Description
TSM-T25 Multiple TUI clients Same user, two connections — both receive events
TSM-T26 API unavailable Returns 502, SSE stays open
TSM-T27 NATS unavailable SSE stays open, events resume when NATS recovers
TSM-T28 Event filtering Events for non-subscribed channels not forwarded
TSM-T29 DM events DM messages forwarded via chat.dm.{channel-id} subscription
TSM-T30 Large init payload Init event with many channels/DMs serializes correctly