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

245 lines
9.1 KiB
Markdown

# 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:
```json
{
"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
```clojure
{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
```clojure
{: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 |
```json
{"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 |