collavre_openclaw 0.2.0 → 0.2.2
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.
- checksums.yaml +4 -4
- data/app/services/collavre_openclaw/ai_client_extension.rb +3 -4
- data/app/services/collavre_openclaw/connection_manager.rb +192 -0
- data/app/services/collavre_openclaw/em_reactor.rb +52 -0
- data/app/services/collavre_openclaw/openclaw_adapter.rb +240 -162
- data/app/services/collavre_openclaw/proactive_message_handler.rb +220 -0
- data/app/services/collavre_openclaw/websocket_client.rb +554 -0
- data/lib/collavre_openclaw/configuration.rb +16 -0
- data/lib/collavre_openclaw/engine.rb +14 -0
- data/lib/collavre_openclaw/errors.rb +6 -0
- data/lib/collavre_openclaw/version.rb +1 -1
- data/lib/collavre_openclaw.rb +1 -0
- metadata +34 -1
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "faye/websocket"
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module CollavreOpenclaw
|
|
7
|
+
# WebSocket client for a single user's OpenClaw Gateway connection.
|
|
8
|
+
#
|
|
9
|
+
# Handles:
|
|
10
|
+
# - Connection lifecycle (connect, disconnect, reconnect)
|
|
11
|
+
# - OpenClaw protocol handshake (connect.challenge → connect → hello-ok)
|
|
12
|
+
# - RPC request/response (chat.send, chat.history, chat.abort)
|
|
13
|
+
# - Event streaming (chat events with delta/final/error states)
|
|
14
|
+
# - Proactive message detection (unsolicited chat events)
|
|
15
|
+
# - Tick keepalive
|
|
16
|
+
#
|
|
17
|
+
# Thread model:
|
|
18
|
+
# - WebSocket runs in the shared EventMachine reactor thread
|
|
19
|
+
# - Rails threads call public methods which bridge via EM.next_tick + Queue
|
|
20
|
+
class WebsocketClient
|
|
21
|
+
PROTOCOL_VERSION = 3
|
|
22
|
+
|
|
23
|
+
attr_reader :user, :state
|
|
24
|
+
|
|
25
|
+
# States: :disconnected, :connecting, :connected, :reconnecting
|
|
26
|
+
def initialize(user:)
|
|
27
|
+
@user = user
|
|
28
|
+
@state = :disconnected
|
|
29
|
+
@ws = nil
|
|
30
|
+
@mutex = Mutex.new
|
|
31
|
+
@connect_mutex = Mutex.new
|
|
32
|
+
@connect_waiters = [] # Queues for threads waiting on in-progress connect
|
|
33
|
+
@pending_requests = {} # id → { queue:, timer: }
|
|
34
|
+
@pending_runs = {} # runId → Queue (for chat.send streaming)
|
|
35
|
+
@proactive_handler = nil
|
|
36
|
+
@reconnect_attempts = 0
|
|
37
|
+
@last_activity_at = nil
|
|
38
|
+
@tick_interval_ms = 15_000
|
|
39
|
+
@tick_timer = nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def connected?
|
|
43
|
+
@state == :connected
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Connect to the Gateway. Blocks until connected or raises on failure.
|
|
47
|
+
# Thread-safe: concurrent callers all wait on the same handshake attempt.
|
|
48
|
+
def connect!
|
|
49
|
+
return if connected?
|
|
50
|
+
|
|
51
|
+
initiator = false
|
|
52
|
+
waiter_queue = nil
|
|
53
|
+
|
|
54
|
+
@connect_mutex.synchronize do
|
|
55
|
+
return if connected?
|
|
56
|
+
|
|
57
|
+
if @state == :connecting
|
|
58
|
+
# Another thread is already connecting — wait on the same handshake
|
|
59
|
+
waiter_queue = Queue.new
|
|
60
|
+
@connect_waiters << waiter_queue
|
|
61
|
+
else
|
|
62
|
+
@state = :connecting
|
|
63
|
+
initiator = true
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
if waiter_queue
|
|
68
|
+
# Wait for the initiating thread to finish handshake
|
|
69
|
+
result = wait_with_timeout(waiter_queue, config.ws_connect_timeout, "connect (waiting)")
|
|
70
|
+
if result[:error]
|
|
71
|
+
raise ConnectionError, result[:error]
|
|
72
|
+
end
|
|
73
|
+
return result
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# This thread initiates the connection
|
|
77
|
+
queue = Queue.new
|
|
78
|
+
|
|
79
|
+
EmReactor.next_tick do
|
|
80
|
+
begin
|
|
81
|
+
do_connect!(queue)
|
|
82
|
+
rescue => e
|
|
83
|
+
queue.push({ error: e.message })
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
result = wait_with_timeout(queue, config.ws_connect_timeout, "connect")
|
|
89
|
+
rescue TimeoutError, StandardError => e
|
|
90
|
+
# Timeout or unexpected error — reset state and wake all waiters
|
|
91
|
+
error_result = { error: e.message }
|
|
92
|
+
@connect_mutex.synchronize do
|
|
93
|
+
@state = :disconnected
|
|
94
|
+
@connect_waiters.each { |q| q.push(error_result) }
|
|
95
|
+
@connect_waiters.clear
|
|
96
|
+
end
|
|
97
|
+
raise ConnectionError, e.message
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Notify all waiting threads
|
|
101
|
+
@connect_mutex.synchronize do
|
|
102
|
+
if result[:error]
|
|
103
|
+
@state = :disconnected
|
|
104
|
+
else
|
|
105
|
+
@state = :connected
|
|
106
|
+
@reconnect_attempts = 0
|
|
107
|
+
touch_activity!
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
@connect_waiters.each { |q| q.push(result) }
|
|
111
|
+
@connect_waiters.clear
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
if result[:error]
|
|
115
|
+
raise ConnectionError, result[:error]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
result
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Disconnect gracefully
|
|
122
|
+
def disconnect!
|
|
123
|
+
@state = :disconnected
|
|
124
|
+
EmReactor.next_tick do
|
|
125
|
+
cancel_tick_timer!
|
|
126
|
+
@ws&.close
|
|
127
|
+
@ws = nil
|
|
128
|
+
end
|
|
129
|
+
# Unblock any waiting requests
|
|
130
|
+
@pending_requests.each_value { |pr| pr[:queue]&.push({ error: "disconnected" }) }
|
|
131
|
+
@pending_requests.clear
|
|
132
|
+
@pending_runs.each_value { |q| q.push({ done: true }) }
|
|
133
|
+
@pending_runs.clear
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Send a chat message. Blocks and yields streaming events.
|
|
137
|
+
#
|
|
138
|
+
# @param session_key [String]
|
|
139
|
+
# @param message [String]
|
|
140
|
+
# @param idempotency_key [String]
|
|
141
|
+
# @yield [Hash] chat events with :state, :text, :message keys
|
|
142
|
+
# @return [String, nil] final response text
|
|
143
|
+
def chat_send(session_key:, message:, idempotency_key: nil, &block)
|
|
144
|
+
ensure_connected!
|
|
145
|
+
touch_activity!
|
|
146
|
+
|
|
147
|
+
idempotency_key ||= SecureRandom.uuid
|
|
148
|
+
actual_run_id = nil
|
|
149
|
+
run_queue = Queue.new
|
|
150
|
+
response_text = +""
|
|
151
|
+
|
|
152
|
+
# Pre-register with idempotency_key to catch early events
|
|
153
|
+
@mutex.synchronize { @pending_runs[idempotency_key] = run_queue }
|
|
154
|
+
|
|
155
|
+
# Send the RPC request to get the real runId
|
|
156
|
+
response = send_rpc("chat.send", {
|
|
157
|
+
sessionKey: session_key,
|
|
158
|
+
message: message,
|
|
159
|
+
idempotencyKey: idempotency_key
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
# Re-register with the Gateway-assigned runId
|
|
163
|
+
actual_run_id = response&.dig(:runId) || idempotency_key
|
|
164
|
+
if actual_run_id != idempotency_key
|
|
165
|
+
@mutex.synchronize do
|
|
166
|
+
@pending_runs.delete(idempotency_key)
|
|
167
|
+
@pending_runs[actual_run_id] = run_queue
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Stream events until final/error/aborted
|
|
172
|
+
loop do
|
|
173
|
+
event = wait_with_timeout(run_queue, config.read_timeout, "chat response")
|
|
174
|
+
|
|
175
|
+
break if event[:done]
|
|
176
|
+
|
|
177
|
+
if event[:error]
|
|
178
|
+
raise ChatError, event[:error]
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
case event[:state]
|
|
182
|
+
when "delta"
|
|
183
|
+
text = extract_event_text(event)
|
|
184
|
+
if text.present?
|
|
185
|
+
response_text << text
|
|
186
|
+
yield({ state: "delta", text: text }) if block_given?
|
|
187
|
+
end
|
|
188
|
+
when "final"
|
|
189
|
+
text = extract_event_text(event)
|
|
190
|
+
yield({ state: "final", text: text, message: event[:message] }) if block_given?
|
|
191
|
+
break
|
|
192
|
+
when "error"
|
|
193
|
+
error_msg = event[:errorMessage] || "Unknown error"
|
|
194
|
+
yield({ state: "error", text: error_msg }) if block_given?
|
|
195
|
+
raise ChatError, error_msg
|
|
196
|
+
when "aborted"
|
|
197
|
+
yield({ state: "aborted" }) if block_given?
|
|
198
|
+
break
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
response_text.presence
|
|
203
|
+
ensure
|
|
204
|
+
@mutex.synchronize do
|
|
205
|
+
@pending_runs.delete(actual_run_id) if actual_run_id
|
|
206
|
+
# Also clean up idempotency_key if send_rpc failed before we got a runId
|
|
207
|
+
@pending_runs.delete(idempotency_key) if idempotency_key
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Fetch chat history for a session
|
|
212
|
+
def chat_history(session_key:, limit: nil)
|
|
213
|
+
ensure_connected!
|
|
214
|
+
touch_activity!
|
|
215
|
+
|
|
216
|
+
params = { sessionKey: session_key }
|
|
217
|
+
params[:limit] = limit if limit
|
|
218
|
+
send_rpc("chat.history", params)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Abort a running chat
|
|
222
|
+
def chat_abort(session_key:, run_id: nil)
|
|
223
|
+
ensure_connected!
|
|
224
|
+
touch_activity!
|
|
225
|
+
|
|
226
|
+
params = { sessionKey: session_key }
|
|
227
|
+
params[:runId] = run_id if run_id
|
|
228
|
+
send_rpc("chat.abort", params)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Register a handler for proactive messages (unsolicited chat events).
|
|
232
|
+
# The handler receives (user, payload) where user is the connection owner.
|
|
233
|
+
def on_proactive_message(&handler)
|
|
234
|
+
@proactive_handler = handler
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Time since last activity (for idle timeout)
|
|
238
|
+
def idle_seconds
|
|
239
|
+
return Float::INFINITY unless @last_activity_at
|
|
240
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_activity_at
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
private
|
|
244
|
+
|
|
245
|
+
def config
|
|
246
|
+
CollavreOpenclaw.config
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def gateway_ws_url
|
|
250
|
+
url = @user.gateway_url.to_s.strip
|
|
251
|
+
return nil if url.blank?
|
|
252
|
+
|
|
253
|
+
uri = URI.parse(url)
|
|
254
|
+
# Convert http(s) to ws(s)
|
|
255
|
+
case uri.scheme
|
|
256
|
+
when "https" then uri.scheme = "wss"
|
|
257
|
+
when "http" then uri.scheme = "ws"
|
|
258
|
+
when "ws", "wss" then # already correct
|
|
259
|
+
else
|
|
260
|
+
uri.scheme = "ws"
|
|
261
|
+
end
|
|
262
|
+
uri.path = "/" if uri.path.blank?
|
|
263
|
+
uri.to_s
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def do_connect!(result_queue)
|
|
267
|
+
url = gateway_ws_url
|
|
268
|
+
unless url.present?
|
|
269
|
+
result_queue.push({ error: "No Gateway URL configured" })
|
|
270
|
+
return
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
@ws = Faye::WebSocket::Client.new(url)
|
|
274
|
+
@handshake_queue = result_queue
|
|
275
|
+
@handshake_done = false
|
|
276
|
+
|
|
277
|
+
@ws.on :open do |_event|
|
|
278
|
+
Rails.logger.info("[CollavreOpenclaw::WS] Connected to #{url}")
|
|
279
|
+
# Wait for connect.challenge from gateway
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
@ws.on :message do |event|
|
|
283
|
+
handle_raw_message(event.data)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
@ws.on :close do |event|
|
|
287
|
+
code = event.code
|
|
288
|
+
reason = event.reason
|
|
289
|
+
Rails.logger.info("[CollavreOpenclaw::WS] Disconnected (code=#{code}, reason=#{reason})")
|
|
290
|
+
|
|
291
|
+
cancel_tick_timer!
|
|
292
|
+
|
|
293
|
+
unless @handshake_done
|
|
294
|
+
@handshake_done = true
|
|
295
|
+
@handshake_queue&.push({ error: "Connection closed during handshake (code=#{code})" })
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
if @state == :connected
|
|
299
|
+
@state = :reconnecting
|
|
300
|
+
schedule_reconnect!
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def handle_raw_message(data)
|
|
306
|
+
touch_activity! # Refresh idle timer on any inbound traffic
|
|
307
|
+
frame = JSON.parse(data, symbolize_names: true)
|
|
308
|
+
|
|
309
|
+
case frame[:type]
|
|
310
|
+
when "event"
|
|
311
|
+
handle_event(frame[:event], frame[:payload])
|
|
312
|
+
when "res"
|
|
313
|
+
handle_response(frame[:id], frame[:ok], frame[:payload], frame[:error])
|
|
314
|
+
end
|
|
315
|
+
rescue JSON::ParserError => e
|
|
316
|
+
Rails.logger.warn("[CollavreOpenclaw::WS] Invalid JSON: #{e.message}")
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def handle_event(event_name, payload)
|
|
320
|
+
case event_name
|
|
321
|
+
when "connect.challenge"
|
|
322
|
+
# Respond with connect request
|
|
323
|
+
send_connect_request(payload)
|
|
324
|
+
when "chat"
|
|
325
|
+
handle_chat_event(payload)
|
|
326
|
+
when "tick"
|
|
327
|
+
handle_tick(payload)
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def send_connect_request(challenge_payload)
|
|
332
|
+
agent_id = extract_agent_id
|
|
333
|
+
device_id = "collavre-#{@user.id}-#{Digest::SHA256.hexdigest(@user.id.to_s)[0..7]}"
|
|
334
|
+
|
|
335
|
+
params = {
|
|
336
|
+
minProtocol: PROTOCOL_VERSION,
|
|
337
|
+
maxProtocol: PROTOCOL_VERSION,
|
|
338
|
+
client: {
|
|
339
|
+
id: "collavre",
|
|
340
|
+
version: CollavreOpenclaw::VERSION,
|
|
341
|
+
platform: "ruby",
|
|
342
|
+
mode: "operator"
|
|
343
|
+
},
|
|
344
|
+
role: "operator",
|
|
345
|
+
scopes: [ "operator.read", "operator.write" ],
|
|
346
|
+
caps: [],
|
|
347
|
+
commands: [],
|
|
348
|
+
permissions: {},
|
|
349
|
+
auth: { token: @user.llm_api_key },
|
|
350
|
+
locale: "en-US",
|
|
351
|
+
userAgent: "collavre-openclaw/#{CollavreOpenclaw::VERSION}",
|
|
352
|
+
device: {
|
|
353
|
+
id: device_id
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
request_id = SecureRandom.uuid
|
|
358
|
+
send_frame({
|
|
359
|
+
type: "req",
|
|
360
|
+
id: request_id,
|
|
361
|
+
method: "connect",
|
|
362
|
+
params: params
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
# Store the request so hello-ok resolves the handshake
|
|
366
|
+
@connect_request_id = request_id
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def handle_response(id, ok, payload, error)
|
|
370
|
+
# Check if this is the connect handshake response
|
|
371
|
+
if id == @connect_request_id && !@handshake_done
|
|
372
|
+
@handshake_done = true
|
|
373
|
+
if ok
|
|
374
|
+
@tick_interval_ms = payload&.dig(:policy, :tickIntervalMs) || 15_000
|
|
375
|
+
@state = :connected
|
|
376
|
+
@reconnect_attempts = 0
|
|
377
|
+
start_tick_timer!
|
|
378
|
+
@handshake_queue&.push({ ok: true, payload: payload })
|
|
379
|
+
else
|
|
380
|
+
error_msg = error&.dig(:message) || error.to_s || "handshake failed"
|
|
381
|
+
@handshake_queue&.push({ error: error_msg })
|
|
382
|
+
end
|
|
383
|
+
@handshake_queue = nil
|
|
384
|
+
return
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Regular RPC response
|
|
388
|
+
pending = @mutex.synchronize { @pending_requests.delete(id) }
|
|
389
|
+
if pending
|
|
390
|
+
if ok
|
|
391
|
+
pending[:queue].push({ ok: true, payload: payload })
|
|
392
|
+
else
|
|
393
|
+
error_msg = error&.dig(:message) || error.to_s || "RPC error"
|
|
394
|
+
pending[:queue].push({ error: error_msg })
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def handle_chat_event(payload)
|
|
400
|
+
run_id = payload[:runId]
|
|
401
|
+
|
|
402
|
+
# Check if this is a response to a pending chat.send
|
|
403
|
+
run_queue = @mutex.synchronize { @pending_runs[run_id] }
|
|
404
|
+
|
|
405
|
+
if run_queue
|
|
406
|
+
# Known run — forward to the waiting thread
|
|
407
|
+
run_queue.push(payload)
|
|
408
|
+
elsif @proactive_handler
|
|
409
|
+
# Unknown run — proactive message from Gateway (cron/heartbeat)
|
|
410
|
+
Rails.logger.info("[CollavreOpenclaw::WS] Proactive message received (runId=#{run_id})")
|
|
411
|
+
@proactive_handler.call(@user, payload)
|
|
412
|
+
else
|
|
413
|
+
Rails.logger.debug("[CollavreOpenclaw::WS] Ignoring chat event for unknown runId=#{run_id}")
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def handle_tick(_payload)
|
|
418
|
+
# Respond with a tick acknowledgment (poll response)
|
|
419
|
+
send_frame({
|
|
420
|
+
type: "req",
|
|
421
|
+
id: SecureRandom.uuid,
|
|
422
|
+
method: "poll",
|
|
423
|
+
params: {}
|
|
424
|
+
})
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def start_tick_timer!
|
|
428
|
+
cancel_tick_timer!
|
|
429
|
+
interval = @tick_interval_ms / 1000.0
|
|
430
|
+
@tick_timer = EM.add_periodic_timer(interval) do
|
|
431
|
+
# Send keepalive poll if the server hasn't sent a tick
|
|
432
|
+
send_frame({
|
|
433
|
+
type: "req",
|
|
434
|
+
id: SecureRandom.uuid,
|
|
435
|
+
method: "poll",
|
|
436
|
+
params: {}
|
|
437
|
+
})
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def cancel_tick_timer!
|
|
442
|
+
@tick_timer&.cancel
|
|
443
|
+
@tick_timer = nil
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
# Send an RPC request and block until the response.
|
|
447
|
+
# Returns the response payload.
|
|
448
|
+
def send_rpc(method, params)
|
|
449
|
+
request_id = SecureRandom.uuid
|
|
450
|
+
queue = Queue.new
|
|
451
|
+
|
|
452
|
+
@mutex.synchronize do
|
|
453
|
+
@pending_requests[request_id] = { queue: queue }
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
EmReactor.next_tick do
|
|
457
|
+
send_frame({
|
|
458
|
+
type: "req",
|
|
459
|
+
id: request_id,
|
|
460
|
+
method: method,
|
|
461
|
+
params: params
|
|
462
|
+
})
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
result = wait_with_timeout(queue, config.read_timeout, method)
|
|
466
|
+
if result[:error]
|
|
467
|
+
raise RpcError, "#{method} failed: #{result[:error]}"
|
|
468
|
+
end
|
|
469
|
+
result[:payload]
|
|
470
|
+
ensure
|
|
471
|
+
@mutex.synchronize { @pending_requests.delete(request_id) }
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
def send_frame(frame)
|
|
475
|
+
return unless @ws
|
|
476
|
+
|
|
477
|
+
data = JSON.generate(frame)
|
|
478
|
+
@ws.send(data)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def ensure_connected!
|
|
482
|
+
unless connected?
|
|
483
|
+
connect!
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
def touch_activity!
|
|
488
|
+
@last_activity_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def schedule_reconnect!
|
|
492
|
+
max = config.ws_reconnect_max
|
|
493
|
+
return if @reconnect_attempts >= max
|
|
494
|
+
|
|
495
|
+
@reconnect_attempts += 1
|
|
496
|
+
delay = config.ws_reconnect_base_delay * (2**(@reconnect_attempts - 1))
|
|
497
|
+
delay = [ delay, 60 ].min # Cap at 60 seconds
|
|
498
|
+
|
|
499
|
+
Rails.logger.info("[CollavreOpenclaw::WS] Reconnecting in #{delay}s (attempt #{@reconnect_attempts}/#{max})")
|
|
500
|
+
|
|
501
|
+
EM.add_timer(delay) do
|
|
502
|
+
next if @state == :disconnected # User explicitly disconnected
|
|
503
|
+
|
|
504
|
+
begin
|
|
505
|
+
queue = Queue.new
|
|
506
|
+
do_connect!(queue)
|
|
507
|
+
|
|
508
|
+
# Handshake result is handled by handle_response which sets @state.
|
|
509
|
+
# Add a timeout to retry if handshake doesn't complete.
|
|
510
|
+
EM.add_timer(config.ws_connect_timeout) do
|
|
511
|
+
unless @handshake_done
|
|
512
|
+
@handshake_done = true
|
|
513
|
+
Rails.logger.warn("[CollavreOpenclaw::WS] Reconnect handshake timed out")
|
|
514
|
+
@ws&.close
|
|
515
|
+
schedule_reconnect!
|
|
516
|
+
end
|
|
517
|
+
end
|
|
518
|
+
rescue => e
|
|
519
|
+
Rails.logger.error("[CollavreOpenclaw::WS] Reconnect failed: #{e.message}")
|
|
520
|
+
schedule_reconnect!
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def extract_event_text(event)
|
|
526
|
+
message = event[:message]
|
|
527
|
+
return nil unless message.is_a?(Hash)
|
|
528
|
+
|
|
529
|
+
content = message[:content]
|
|
530
|
+
case content
|
|
531
|
+
when String
|
|
532
|
+
content
|
|
533
|
+
when Array
|
|
534
|
+
content.filter_map { |c| c[:text] if c[:type] == "text" }.join
|
|
535
|
+
else
|
|
536
|
+
nil
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
def extract_agent_id
|
|
541
|
+
return nil unless @user&.email.present?
|
|
542
|
+
@user.email.split("@").first
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
def wait_with_timeout(queue, timeout_seconds, operation)
|
|
546
|
+
# Use Queue#pop(timeout:) instead of Timeout.timeout to avoid Thread.raise corruption
|
|
547
|
+
result = queue.pop(timeout: timeout_seconds)
|
|
548
|
+
if result.nil? && queue.empty?
|
|
549
|
+
raise TimeoutError, "#{operation} timed out after #{timeout_seconds}s"
|
|
550
|
+
end
|
|
551
|
+
result
|
|
552
|
+
end
|
|
553
|
+
end
|
|
554
|
+
end
|
|
@@ -10,10 +10,26 @@ module CollavreOpenclaw
|
|
|
10
10
|
# Max retries for transient failures
|
|
11
11
|
attr_accessor :max_retries
|
|
12
12
|
|
|
13
|
+
# WebSocket idle timeout (seconds) - disconnect after inactivity
|
|
14
|
+
attr_accessor :ws_idle_timeout
|
|
15
|
+
|
|
16
|
+
# WebSocket max reconnect attempts before giving up
|
|
17
|
+
attr_accessor :ws_reconnect_max
|
|
18
|
+
|
|
19
|
+
# WebSocket reconnect base delay (seconds) - exponential backoff base
|
|
20
|
+
attr_accessor :ws_reconnect_base_delay
|
|
21
|
+
|
|
22
|
+
# WebSocket connect timeout (seconds)
|
|
23
|
+
attr_accessor :ws_connect_timeout
|
|
24
|
+
|
|
13
25
|
def initialize
|
|
14
26
|
@open_timeout = ENV.fetch("OPENCLAW_OPEN_TIMEOUT", 10).to_i
|
|
15
27
|
@read_timeout = ENV.fetch("OPENCLAW_READ_TIMEOUT", 180).to_i # 3 minutes for AI responses
|
|
16
28
|
@max_retries = ENV.fetch("OPENCLAW_MAX_RETRIES", 2).to_i
|
|
29
|
+
@ws_idle_timeout = ENV.fetch("OPENCLAW_WS_IDLE_TIMEOUT", 1800).to_i # 30 minutes
|
|
30
|
+
@ws_reconnect_max = ENV.fetch("OPENCLAW_WS_RECONNECT_MAX", 10).to_i
|
|
31
|
+
@ws_reconnect_base_delay = ENV.fetch("OPENCLAW_WS_RECONNECT_BASE", 1).to_f
|
|
32
|
+
@ws_connect_timeout = ENV.fetch("OPENCLAW_WS_CONNECT_TIMEOUT", 10).to_i
|
|
17
33
|
end
|
|
18
34
|
|
|
19
35
|
# Legacy accessor for backward compatibility
|
|
@@ -40,5 +40,19 @@ module CollavreOpenclaw
|
|
|
40
40
|
end
|
|
41
41
|
end
|
|
42
42
|
end
|
|
43
|
+
|
|
44
|
+
# Graceful shutdown: disconnect all WebSocket connections.
|
|
45
|
+
# Only clean up if the singleton was already instantiated to avoid
|
|
46
|
+
# starting EM/threads during shutdown or test teardown.
|
|
47
|
+
config.after_initialize do
|
|
48
|
+
at_exit do
|
|
49
|
+
if ConnectionManager.instance_variable_get(:@singleton__instance__)
|
|
50
|
+
ConnectionManager.instance.disconnect_all
|
|
51
|
+
end
|
|
52
|
+
EmReactor.stop! if EmReactor.running?
|
|
53
|
+
rescue => e
|
|
54
|
+
Rails.logger.warn("[CollavreOpenclaw] Shutdown cleanup error: #{e.message}")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
43
57
|
end
|
|
44
58
|
end
|
data/lib/collavre_openclaw.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: collavre_openclaw
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Collavre
|
|
@@ -37,6 +37,34 @@ dependencies:
|
|
|
37
37
|
- - ">="
|
|
38
38
|
- !ruby/object:Gem::Version
|
|
39
39
|
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: faye-websocket
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '0.11'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0.11'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: eventmachine
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '1.2'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '1.2'
|
|
40
68
|
description: Enables AI agents in Collavre to use OpenClaw as their LLM backend
|
|
41
69
|
email:
|
|
42
70
|
- support@collavre.com
|
|
@@ -54,7 +82,11 @@ files:
|
|
|
54
82
|
- app/models/collavre_openclaw/application_record.rb
|
|
55
83
|
- app/models/collavre_openclaw/pending_callback.rb
|
|
56
84
|
- app/services/collavre_openclaw/ai_client_extension.rb
|
|
85
|
+
- app/services/collavre_openclaw/connection_manager.rb
|
|
86
|
+
- app/services/collavre_openclaw/em_reactor.rb
|
|
57
87
|
- app/services/collavre_openclaw/openclaw_adapter.rb
|
|
88
|
+
- app/services/collavre_openclaw/proactive_message_handler.rb
|
|
89
|
+
- app/services/collavre_openclaw/websocket_client.rb
|
|
58
90
|
- config/initializers/ai_client_extension.rb
|
|
59
91
|
- config/locales/en.yml
|
|
60
92
|
- config/locales/ko.yml
|
|
@@ -72,6 +104,7 @@ files:
|
|
|
72
104
|
- lib/collavre_openclaw.rb
|
|
73
105
|
- lib/collavre_openclaw/configuration.rb
|
|
74
106
|
- lib/collavre_openclaw/engine.rb
|
|
107
|
+
- lib/collavre_openclaw/errors.rb
|
|
75
108
|
- lib/collavre_openclaw/version.rb
|
|
76
109
|
homepage: https://github.com/sh1nj1/plan42
|
|
77
110
|
licenses:
|