anima-core 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,377 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "websocket-client-simple"
4
+ require "json"
5
+
6
+ module TUI
7
+ # Action Cable WebSocket client for connecting the TUI to the brain server.
8
+ # Runs the WebSocket connection in a background thread and exposes a
9
+ # thread-safe message queue for the TUI render loop to drain.
10
+ #
11
+ # Implements the +actioncable-v1-json+ protocol: subscribe to a
12
+ # {SessionChannel}, receive event broadcasts, and send user input
13
+ # via the +speak+ action.
14
+ #
15
+ # Automatically reconnects with exponential backoff when the connection
16
+ # drops unexpectedly. Detects stale connections via Action Cable ping
17
+ # heartbeat monitoring.
18
+ #
19
+ # @example
20
+ # client = TUI::CableClient.new(host: "localhost:42134", session_id: 1)
21
+ # client.connect
22
+ # client.speak("Hello!")
23
+ # messages = client.drain_messages
24
+ # client.disconnect
25
+ class CableClient
26
+ DISCONNECT_TIMEOUT = 2 # seconds to wait for WebSocket thread to finish
27
+ POLL_INTERVAL = 0.1 # seconds between connection status checks
28
+ CONNECTION_TIMEOUT = 10 # seconds to wait for the connecting state to advance
29
+ MAX_RECONNECT_ATTEMPTS = 10
30
+ BACKOFF_BASE = 1.0 # initial backoff delay in seconds
31
+ BACKOFF_CAP = 30.0 # maximum backoff delay
32
+ PING_STALE_THRESHOLD = 6.0 # seconds without ping before connection is stale
33
+
34
+ # @return [String] brain server host:port
35
+ attr_reader :host
36
+
37
+ # @return [Integer] current session ID
38
+ attr_reader :session_id
39
+
40
+ # @return [Symbol] connection status (:disconnected, :connecting, :connected, :subscribed, :reconnecting)
41
+ attr_reader :status
42
+
43
+ # @return [Integer] current reconnection attempt (0 when connected)
44
+ attr_reader :reconnect_attempt
45
+
46
+ # @param host [String] brain server address (e.g. "localhost:42134")
47
+ # @param session_id [Integer] session to subscribe to
48
+ def initialize(host:, session_id:)
49
+ @host = host
50
+ @session_id = session_id
51
+ @status = :disconnected
52
+ @message_queue = Thread::Queue.new
53
+ @mutex = Mutex.new
54
+ @ws = nil
55
+ @ws_thread = nil
56
+ @intentional_disconnect = false
57
+ @reconnect_attempt = 0
58
+ @last_ping_at = nil
59
+ @connection_generation = 0
60
+ end
61
+
62
+ # Opens the WebSocket connection in a background thread.
63
+ # The connection subscribes to the session channel automatically
64
+ # after receiving the Action Cable welcome message. Reconnects
65
+ # automatically on unexpected disconnection.
66
+ def connect
67
+ @mutex.synchronize do
68
+ @intentional_disconnect = false
69
+ @status = :connecting
70
+ end
71
+ @ws_thread = Thread.new { run_websocket_loop }
72
+ end
73
+
74
+ # Sends user input to the brain for processing.
75
+ #
76
+ # @param content [String] the user's message text
77
+ def speak(content)
78
+ send_action("speak", {"content" => content})
79
+ end
80
+
81
+ # Requests the brain to create a new session and switch to it.
82
+ # The server responds with a session_changed message followed by history.
83
+ def create_session
84
+ send_action("create_session", {})
85
+ end
86
+
87
+ # Requests the brain to switch to an existing session.
88
+ # The server responds with a session_changed message followed by history.
89
+ #
90
+ # @param session_id [Integer] target session to resume
91
+ def switch_session(session_id)
92
+ send_action("switch_session", {"session_id" => session_id})
93
+ end
94
+
95
+ # Requests a list of recent sessions from the brain.
96
+ # The server responds with a sessions_list message.
97
+ #
98
+ # @param limit [Integer] max sessions to return (default 10)
99
+ def list_sessions(limit: 10)
100
+ send_action("list_sessions", {"limit" => limit})
101
+ end
102
+
103
+ # Updates the local session ID reference after a server-side session switch.
104
+ #
105
+ # @param new_id [Integer] the new session ID
106
+ def update_session_id(new_id)
107
+ @mutex.synchronize { @session_id = new_id }
108
+ end
109
+
110
+ # Drains all pending messages from the queue (non-blocking).
111
+ # Call this from the TUI render loop to process incoming events.
112
+ #
113
+ # @return [Array<Hash>] messages received since last drain
114
+ def drain_messages
115
+ messages = []
116
+ loop do
117
+ messages << @message_queue.pop(true)
118
+ rescue ThreadError
119
+ break
120
+ end
121
+ messages
122
+ end
123
+
124
+ # Unsubscribes from the current session and subscribes to a new one.
125
+ #
126
+ # @deprecated Use {#create_session} or {#switch_session} instead.
127
+ # The server now handles stream switching via the session protocol.
128
+ # @param new_session_id [Integer] session to switch to
129
+ def resubscribe(new_session_id)
130
+ unsubscribe_current
131
+ @mutex.synchronize { @session_id = new_session_id }
132
+ subscribe
133
+ end
134
+
135
+ # Closes the WebSocket connection and cleans up the background thread.
136
+ # Prevents automatic reconnection.
137
+ def disconnect
138
+ @mutex.synchronize do
139
+ @intentional_disconnect = true
140
+ @status = :disconnected
141
+ end
142
+ @ws&.close
143
+ @ws_thread&.join(DISCONNECT_TIMEOUT)
144
+ end
145
+
146
+ private
147
+
148
+ # Main connection loop: connect -> monitor -> reconnect if needed.
149
+ # Runs in a background thread spawned by {#connect}.
150
+ def run_websocket_loop
151
+ loop do
152
+ return if intentional_disconnect?
153
+
154
+ if open_websocket
155
+ monitor_connection
156
+ end
157
+
158
+ return if intentional_disconnect?
159
+ break unless schedule_reconnect
160
+ end
161
+ rescue => _e
162
+ on_disconnected
163
+ end
164
+
165
+ # Establishes WebSocket connection and registers event handlers.
166
+ #
167
+ # @return [Boolean] true if connection was opened, false on failure
168
+ def open_websocket
169
+ begin
170
+ @ws&.close
171
+ rescue IOError, Errno::ECONNRESET
172
+ nil
173
+ end
174
+
175
+ generation = @mutex.synchronize do
176
+ @connection_generation += 1
177
+ @status = :connecting
178
+ @last_ping_at = nil
179
+ @connection_generation
180
+ end
181
+
182
+ url = "ws://#{@host}/cable"
183
+ client = self
184
+
185
+ @ws = WebSocket::Client::Simple.connect(url, headers: {
186
+ "Sec-WebSocket-Protocol" => "actioncable-v1-json"
187
+ })
188
+
189
+ @ws.on :open do
190
+ # Wait for welcome message from Action Cable
191
+ end
192
+
193
+ @ws.on :message do |msg|
194
+ next if client.send(:stale_generation?, generation)
195
+ data = JSON.parse(msg.data)
196
+ client.send(:handle_protocol_message, data)
197
+ rescue JSON::ParserError
198
+ # Ignore malformed messages
199
+ end
200
+
201
+ @ws.on :close do |_e|
202
+ client.send(:on_disconnected) unless client.send(:stale_generation?, generation)
203
+ end
204
+
205
+ @ws.on :error do |_e|
206
+ client.send(:on_disconnected) unless client.send(:stale_generation?, generation)
207
+ end
208
+
209
+ true
210
+ rescue Errno::ECONNREFUSED, Errno::ETIMEDOUT, Errno::EHOSTUNREACH,
211
+ SocketError, IOError => _e
212
+ false
213
+ end
214
+
215
+ # Polls connection status until disconnect is detected.
216
+ # Also monitors for stale connections and connection timeout.
217
+ def monitor_connection
218
+ connection_start = Time.now
219
+
220
+ loop do
221
+ break if @status == :disconnected
222
+
223
+ if @status == :connecting && (Time.now - connection_start) > CONNECTION_TIMEOUT
224
+ on_disconnected
225
+ break
226
+ end
227
+
228
+ check_stale_connection
229
+ sleep POLL_INTERVAL
230
+ end
231
+ end
232
+
233
+ # Detects stale connections by monitoring ping heartbeat interval.
234
+ # Action Cable sends pings approximately every 3 seconds;
235
+ # a 6-second gap indicates 2 missed pings.
236
+ def check_stale_connection
237
+ stale = @mutex.synchronize do
238
+ next false unless @last_ping_at && @status == :subscribed
239
+ (Time.now - @last_ping_at) >= PING_STALE_THRESHOLD
240
+ end
241
+
242
+ on_disconnected if stale
243
+ end
244
+
245
+ # Waits with exponential backoff before next reconnection attempt.
246
+ #
247
+ # @return [Boolean] true if reconnection should proceed, false if max attempts reached
248
+ def schedule_reconnect
249
+ attempt = @mutex.synchronize do
250
+ @reconnect_attempt += 1
251
+ @reconnect_attempt
252
+ end
253
+
254
+ if attempt > MAX_RECONNECT_ATTEMPTS
255
+ @mutex.synchronize { @status = :disconnected }
256
+ @message_queue << {
257
+ "type" => "connection",
258
+ "status" => "failed",
259
+ "message" => "Reconnection failed after #{MAX_RECONNECT_ATTEMPTS} attempts"
260
+ }
261
+ return false
262
+ end
263
+
264
+ delay = backoff_delay(attempt)
265
+ @mutex.synchronize { @status = :reconnecting }
266
+ @message_queue << {
267
+ "type" => "connection",
268
+ "status" => "reconnecting",
269
+ "attempt" => attempt,
270
+ "max_attempts" => MAX_RECONNECT_ATTEMPTS,
271
+ "delay" => delay.round(1)
272
+ }
273
+
274
+ sleep delay
275
+ !intentional_disconnect?
276
+ end
277
+
278
+ # Full jitter backoff: random delay between 0 and min(cap, base * 2^attempt).
279
+ # Prevents thundering herd when multiple clients reconnect simultaneously.
280
+ #
281
+ # @param attempt [Integer] current attempt number (1-based)
282
+ # @return [Float] delay in seconds
283
+ def backoff_delay(attempt)
284
+ max_delay = [BACKOFF_CAP, BACKOFF_BASE * (2**(attempt - 1))].min
285
+ rand(0.0..max_delay)
286
+ end
287
+
288
+ # Checks if a captured connection generation is outdated.
289
+ # WebSocket event handlers capture the generation at connection time;
290
+ # if a new connection starts, older handlers must ignore their events
291
+ # to prevent stale callbacks from corrupting current state.
292
+ #
293
+ # @param generation [Integer] the generation captured by an event handler
294
+ # @return [Boolean] true if the given generation is no longer current
295
+ def stale_generation?(generation)
296
+ @mutex.synchronize { generation != @connection_generation }
297
+ end
298
+
299
+ # @return [Boolean] true if disconnect was initiated by the application
300
+ def intentional_disconnect?
301
+ @mutex.synchronize { @intentional_disconnect }
302
+ end
303
+
304
+ def handle_protocol_message(data)
305
+ case data["type"]
306
+ when "welcome"
307
+ @mutex.synchronize { @status = :connected }
308
+ @last_ping_at = Time.now
309
+ subscribe
310
+ when "ping"
311
+ @last_ping_at = Time.now
312
+ when "confirm_subscription"
313
+ @mutex.synchronize do
314
+ @status = :subscribed
315
+ @reconnect_attempt = 0
316
+ end
317
+ @message_queue << {"type" => "connection", "status" => "subscribed"}
318
+ when "reject_subscription"
319
+ on_disconnected
320
+ @message_queue << {"type" => "connection", "status" => "rejected"}
321
+ when "disconnect"
322
+ if data["reconnect"] == false
323
+ @mutex.synchronize do
324
+ @intentional_disconnect = true
325
+ @status = :disconnected
326
+ end
327
+ @message_queue << {"type" => "connection", "status" => "disconnected"}
328
+ else
329
+ on_disconnected
330
+ end
331
+ else
332
+ # Regular broadcast or transmit from the channel
333
+ if data["message"]
334
+ @message_queue << data["message"]
335
+ end
336
+ end
337
+ end
338
+
339
+ # Transitions to disconnected state. Guards against duplicate calls
340
+ # from concurrent close/error handlers.
341
+ def on_disconnected
342
+ @mutex.synchronize do
343
+ return if @status == :disconnected || @status == :reconnecting
344
+ @status = :disconnected
345
+ end
346
+ @message_queue << {"type" => "connection", "status" => "disconnected"}
347
+ end
348
+
349
+ def subscribe
350
+ identifier = {channel: "SessionChannel", session_id: @session_id}.to_json
351
+ send_command("subscribe", identifier)
352
+ end
353
+
354
+ def unsubscribe_current
355
+ identifier = {channel: "SessionChannel", session_id: @session_id}.to_json
356
+ send_command("unsubscribe", identifier)
357
+ end
358
+
359
+ def send_action(action, data = {})
360
+ identifier = {channel: "SessionChannel", session_id: @session_id}.to_json
361
+ payload = data.merge("action" => action).to_json
362
+
363
+ @ws&.send({
364
+ command: "message",
365
+ identifier: identifier,
366
+ data: payload
367
+ }.to_json)
368
+ end
369
+
370
+ def send_command(command, identifier)
371
+ @ws&.send({
372
+ command: command,
373
+ identifier: identifier
374
+ }.to_json)
375
+ end
376
+ end
377
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TUI
4
+ # Thread-safe in-memory store for chat messages displayed in the TUI.
5
+ # Replaces {Events::Subscribers::MessageCollector} in the WebSocket-based
6
+ # TUI, with no dependency on Rails or the Events module.
7
+ #
8
+ # Accepts Action Cable event payloads and extracts displayable messages.
9
+ class MessageStore
10
+ DISPLAYABLE_TYPES = %w[user_message agent_message].freeze
11
+
12
+ ROLE_MAP = {
13
+ "user_message" => "user",
14
+ "agent_message" => "assistant"
15
+ }.freeze
16
+
17
+ def initialize
18
+ @messages = []
19
+ @mutex = Mutex.new
20
+ end
21
+
22
+ # @return [Array<Hash>] thread-safe copy of collected messages
23
+ def messages
24
+ @mutex.synchronize { @messages.dup }
25
+ end
26
+
27
+ # Processes a raw event payload from the WebSocket channel.
28
+ # Only user_message and agent_message events are stored.
29
+ #
30
+ # @param event_data [Hash] Action Cable event payload with "type" and "content"
31
+ # @return [Boolean] true if the message was stored
32
+ def process_event(event_data)
33
+ type = event_data["type"]
34
+ return false unless DISPLAYABLE_TYPES.include?(type)
35
+
36
+ content = event_data["content"]
37
+ return false if content.nil?
38
+
39
+ @mutex.synchronize do
40
+ @messages << {role: ROLE_MAP.fetch(type), content: content}
41
+ end
42
+ true
43
+ end
44
+
45
+ def clear
46
+ @mutex.synchronize { @messages = [] }
47
+ end
48
+ end
49
+ end