collavre_openclaw 0.4.0 → 0.5.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.
- checksums.yaml +4 -4
- data/app/controllers/collavre_openclaw/callbacks_controller.rb +1 -0
- data/app/controllers/collavre_openclaw/health_controller.rb +18 -1
- data/app/jobs/collavre_openclaw/callback_processor_job.rb +13 -1
- data/app/services/collavre_openclaw/connection_manager.rb +51 -4
- data/app/services/collavre_openclaw/openclaw_adapter.rb +111 -48
- data/app/services/collavre_openclaw/websocket_client.rb +124 -65
- data/lib/collavre_openclaw/configuration.rb +1 -1
- data/lib/collavre_openclaw/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 47fb3ac9892c9b3c1ee7531645732dd47acdba3b7504d048cd207ff8ae4fa82e
|
|
4
|
+
data.tar.gz: e6233d85c5bff7f28ad2201b0cb84df1fe6c62827cd23666de54ef64060f41d5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a8b34146dedfcec60bc2b0b78ac4f9f51448c8b2bd3ee9064e24d24ac7854271e8cb943b5083da143ec9a18e7470b869e927f2b680db1518ff6a324897a0dc9
|
|
7
|
+
data.tar.gz: 6f97da70c3443dcda6087cfa114b5f068cb952e567b90849a07b27239d6df89c55cafa07f35e76a2c864337d02e56f9cc328e5bc33f8655af4fd87ec93c4dee7
|
|
@@ -66,6 +66,7 @@ module CollavreOpenclaw
|
|
|
66
66
|
payload[:context][:creative_id] ||= pending.creative_id
|
|
67
67
|
payload[:context][:comment_id] ||= pending.comment_id
|
|
68
68
|
payload[:context][:thread_id] ||= pending.thread_id
|
|
69
|
+
payload[:context][:topic_id] ||= pending.thread_id
|
|
69
70
|
|
|
70
71
|
# Merge any extra context stored in pending callback
|
|
71
72
|
if pending.context.present?
|
|
@@ -3,11 +3,28 @@ module CollavreOpenclaw
|
|
|
3
3
|
allow_unauthenticated_access only: :show
|
|
4
4
|
|
|
5
5
|
def show
|
|
6
|
-
|
|
6
|
+
payload = {
|
|
7
7
|
status: "ok",
|
|
8
8
|
engine: "collavre_openclaw",
|
|
9
9
|
version: CollavreOpenclaw::VERSION
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
# WebSocket details only for authenticated requests
|
|
13
|
+
if authenticated?
|
|
14
|
+
payload[:transport] = CollavreOpenclaw.config.transport
|
|
15
|
+
payload[:websocket] = ConnectionManager.status_summary
|
|
16
|
+
payload[:reactor] = { running: EmReactor.running? }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
render json: payload
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def authenticated?
|
|
25
|
+
respond_to?(:current_user, true) && current_user.present?
|
|
26
|
+
rescue
|
|
27
|
+
false
|
|
11
28
|
end
|
|
12
29
|
end
|
|
13
30
|
end
|
|
@@ -39,6 +39,8 @@ module CollavreOpenclaw
|
|
|
39
39
|
comment_id = context[:comment_id]
|
|
40
40
|
content = payload[:content] || payload[:message]
|
|
41
41
|
|
|
42
|
+
normalize_topic_context!(context)
|
|
43
|
+
|
|
42
44
|
if comment_id.present?
|
|
43
45
|
# Update existing comment (streaming completion)
|
|
44
46
|
comment = Collavre::Comment.find_by(id: comment_id)
|
|
@@ -56,7 +58,7 @@ module CollavreOpenclaw
|
|
|
56
58
|
def handle_proactive(payload)
|
|
57
59
|
creative_id = payload[:creative_id] || payload.dig(:context, :creative_id)
|
|
58
60
|
content = payload[:content] || payload[:message]
|
|
59
|
-
thread_id = payload[:thread_id] || payload.dig(:context, :thread_id)
|
|
61
|
+
thread_id = payload[:thread_id] || payload[:topic_id] || payload.dig(:context, :thread_id) || payload.dig(:context, :topic_id)
|
|
60
62
|
parent_comment_id = payload[:parent_comment_id] || payload.dig(:context, :parent_comment_id)
|
|
61
63
|
|
|
62
64
|
unless creative_id.present?
|
|
@@ -94,6 +96,8 @@ module CollavreOpenclaw
|
|
|
94
96
|
end
|
|
95
97
|
|
|
96
98
|
def create_ai_comment(creative_id, content, context = {})
|
|
99
|
+
normalize_topic_context!(context)
|
|
100
|
+
|
|
97
101
|
creative = Collavre::Creative.find_by(id: creative_id)
|
|
98
102
|
unless creative
|
|
99
103
|
Rails.logger.error("[CollavreOpenclaw] Creative not found: #{creative_id}")
|
|
@@ -135,5 +139,13 @@ module CollavreOpenclaw
|
|
|
135
139
|
Rails.logger.error("[CollavreOpenclaw] Failed to create comment: #{e.message}")
|
|
136
140
|
nil
|
|
137
141
|
end
|
|
142
|
+
|
|
143
|
+
def normalize_topic_context!(context)
|
|
144
|
+
return unless context.is_a?(Hash)
|
|
145
|
+
return if context[:thread_id].present?
|
|
146
|
+
|
|
147
|
+
topic_id = context[:topic_id]
|
|
148
|
+
context[:thread_id] = topic_id if topic_id.present?
|
|
149
|
+
end
|
|
138
150
|
end
|
|
139
151
|
end
|
|
@@ -43,10 +43,17 @@ module CollavreOpenclaw
|
|
|
43
43
|
client = @connections[gateway_url]
|
|
44
44
|
|
|
45
45
|
if client.nil?
|
|
46
|
-
client =
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
46
|
+
client = create_client(user, gateway_url)
|
|
47
|
+
elsif client.user.llm_api_key != user.llm_api_key
|
|
48
|
+
# Same gateway_url but different API key. This is a configuration
|
|
49
|
+
# error: one Gateway = one API key. Log a warning so the admin
|
|
50
|
+
# notices, rather than silently ignoring the second user's key.
|
|
51
|
+
# In HTTP mode this would surface as a 401 on each request.
|
|
52
|
+
Rails.logger.warn(
|
|
53
|
+
"[CollavreOpenclaw::ConnectionManager] API key mismatch for gateway #{gateway_url}: " \
|
|
54
|
+
"user #{user.id} has a different key than connection owner #{client.user.id}. " \
|
|
55
|
+
"The connection uses the owner's key. Verify AI agent settings."
|
|
56
|
+
)
|
|
50
57
|
end
|
|
51
58
|
|
|
52
59
|
# Track this user as using this gateway
|
|
@@ -109,6 +116,17 @@ module CollavreOpenclaw
|
|
|
109
116
|
end
|
|
110
117
|
end
|
|
111
118
|
|
|
119
|
+
# Safe accessor: returns status without triggering singleton initialization.
|
|
120
|
+
# Use this from controllers/monitoring instead of instance_variable_get.
|
|
121
|
+
def self.status_summary
|
|
122
|
+
if instance_variable_defined?(:@singleton__instance__) && @singleton__instance__
|
|
123
|
+
instance.status
|
|
124
|
+
else
|
|
125
|
+
{ total_connections: 0, total_users: 0,
|
|
126
|
+
connected: 0, connecting: 0, reconnecting: 0, disconnected: 0 }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
112
130
|
# Register a proactive message handler for all connections.
|
|
113
131
|
# New connections will also get this handler.
|
|
114
132
|
def on_proactive_message(&handler)
|
|
@@ -138,6 +156,35 @@ module CollavreOpenclaw
|
|
|
138
156
|
|
|
139
157
|
private
|
|
140
158
|
|
|
159
|
+
# Create a new WebsocketClient and wire up handlers.
|
|
160
|
+
def create_client(user, gateway_url)
|
|
161
|
+
client = WebsocketClient.new(user: user)
|
|
162
|
+
client.on_proactive_message(&@proactive_handler) if @proactive_handler
|
|
163
|
+
client.on_fatal_close do |dead_client|
|
|
164
|
+
handle_fatal_close(gateway_url, dead_client)
|
|
165
|
+
end
|
|
166
|
+
@connections[gateway_url] = client
|
|
167
|
+
@gateway_users[gateway_url] ||= Set.new
|
|
168
|
+
client
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Called when a client receives a fatal close code (auth failure, etc.).
|
|
172
|
+
# Removes the dead client so the next connection_for call creates a fresh one.
|
|
173
|
+
#
|
|
174
|
+
# Guard: only deletes if the current mapping still points to dead_client.
|
|
175
|
+
# Without this, a late-arriving callback from an old client could wipe a
|
|
176
|
+
# new live connection that was already registered for the same gateway_url.
|
|
177
|
+
def handle_fatal_close(gateway_url, dead_client)
|
|
178
|
+
@mutex.synchronize do
|
|
179
|
+
return unless @connections[gateway_url].equal?(dead_client)
|
|
180
|
+
|
|
181
|
+
Rails.logger.warn("[CollavreOpenclaw::ConnectionManager] Fatal close for gateway #{gateway_url}, removing connection")
|
|
182
|
+
@connections.delete(gateway_url)
|
|
183
|
+
user_ids = @gateway_users.delete(gateway_url) || Set.new
|
|
184
|
+
user_ids.each { |uid| @user_gateways.delete(uid) }
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
141
188
|
# Set up the default proactive message handler that dispatches
|
|
142
189
|
# unsolicited chat events to CallbackProcessorJob.
|
|
143
190
|
def setup_default_proactive_handler!
|
|
@@ -37,7 +37,7 @@ module CollavreOpenclaw
|
|
|
37
37
|
# Try WebSocket first, fall back to HTTP
|
|
38
38
|
# Set OPENCLAW_TRANSPORT=http to force HTTP-only mode
|
|
39
39
|
if CollavreOpenclaw.config.transport == "http"
|
|
40
|
-
Rails.logger.info("[CollavreOpenclaw]
|
|
40
|
+
Rails.logger.info("[CollavreOpenclaw::WS] TRANSPORT mode=http_forced")
|
|
41
41
|
chat_via_http(messages, &block)
|
|
42
42
|
elsif websocket_available?
|
|
43
43
|
chat_via_websocket(messages, &block)
|
|
@@ -88,13 +88,14 @@ module CollavreOpenclaw
|
|
|
88
88
|
|
|
89
89
|
begin
|
|
90
90
|
client = ConnectionManager.instance.connection_for(@user)
|
|
91
|
-
|
|
91
|
+
payload = build_ws_chat_payload(messages)
|
|
92
92
|
|
|
93
93
|
Rails.logger.info("[CollavreOpenclaw] Sending via WebSocket (session: #{session_key})")
|
|
94
94
|
|
|
95
95
|
client.chat_send(
|
|
96
96
|
session_key: session_key,
|
|
97
|
-
message:
|
|
97
|
+
message: payload[:message],
|
|
98
|
+
attachments: payload[:attachments]
|
|
98
99
|
) do |event|
|
|
99
100
|
case event[:state]
|
|
100
101
|
when "delta"
|
|
@@ -119,7 +120,7 @@ module CollavreOpenclaw
|
|
|
119
120
|
response_content.presence
|
|
120
121
|
rescue CollavreOpenclaw::ConnectionError,
|
|
121
122
|
CollavreOpenclaw::TimeoutError => e
|
|
122
|
-
Rails.logger.warn("[CollavreOpenclaw]
|
|
123
|
+
Rails.logger.warn("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
|
|
123
124
|
chat_via_http(messages, &block)
|
|
124
125
|
rescue CollavreOpenclaw::ChatError, CollavreOpenclaw::RpcError => e
|
|
125
126
|
Rails.logger.error("[CollavreOpenclaw] WebSocket chat error: #{e.message}")
|
|
@@ -129,64 +130,74 @@ module CollavreOpenclaw
|
|
|
129
130
|
rescue StandardError => e
|
|
130
131
|
Rails.logger.error("[CollavreOpenclaw] WebSocket unexpected error: #{e.message}\n" \
|
|
131
132
|
"#{e.backtrace.first(5).join("\n")}")
|
|
132
|
-
Rails.logger.info("[CollavreOpenclaw]
|
|
133
|
+
Rails.logger.info("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
|
|
133
134
|
chat_via_http(messages, &block)
|
|
134
135
|
end
|
|
135
136
|
end
|
|
136
137
|
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
138
|
+
# Build WebSocket chat.send payload.
|
|
139
|
+
#
|
|
140
|
+
# Includes the same full text context as HTTP mode plus optional base64
|
|
141
|
+
# image attachments supported by the Gateway's chat.send.attachments field.
|
|
142
|
+
def build_ws_chat_payload(messages)
|
|
143
|
+
{
|
|
144
|
+
message: format_message_for_ws(messages),
|
|
145
|
+
attachments: extract_ws_attachments(messages).presence
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Format messages for WebSocket chat.send text payload.
|
|
150
|
+
#
|
|
151
|
+
# Includes full context on EVERY request, matching HTTP mode behavior.
|
|
152
|
+
# The Gateway's WS session may be new (no prior history), so we cannot
|
|
153
|
+
# assume context was sent before. This is consistent with how the HTTP
|
|
154
|
+
# Chat Completions API works (full history on every request).
|
|
155
|
+
#
|
|
156
|
+
# Message structure sent:
|
|
157
|
+
# [system prompt]
|
|
158
|
+
# [creative context messages]
|
|
159
|
+
# [context creative messages]
|
|
160
|
+
# [chat history]
|
|
161
|
+
# [latest user message]
|
|
140
162
|
def format_message_for_ws(messages)
|
|
141
163
|
formatted = Array(messages)
|
|
164
|
+
return "" if formatted.empty?
|
|
142
165
|
|
|
143
|
-
|
|
144
|
-
last_user = formatted.reverse.find do |m|
|
|
145
|
-
role = m[:role] || m["role"]
|
|
146
|
-
role.to_s == "user"
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
return "" unless last_user
|
|
166
|
+
parts = []
|
|
150
167
|
|
|
151
|
-
|
|
168
|
+
# 1. System prompt (same as HTTP mode's build_payload)
|
|
169
|
+
parts << @system_prompt if @system_prompt.present?
|
|
152
170
|
|
|
153
|
-
#
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if context_prefix.present?
|
|
158
|
-
return "#{context_prefix}\n\n#{text}"
|
|
159
|
-
end
|
|
160
|
-
end
|
|
171
|
+
# 2. All context messages (Creative:, Context Creative:, Referenced Creative:)
|
|
172
|
+
formatted.each do |m|
|
|
173
|
+
role = m[:role] || m["role"]
|
|
174
|
+
next unless role.to_s == "user"
|
|
161
175
|
|
|
162
|
-
|
|
163
|
-
|
|
176
|
+
text = extract_message_text(m)
|
|
177
|
+
next unless text.present?
|
|
178
|
+
next unless text.match?(/\A(Creative|Context Creative|Referenced Creative)\s*\(/)
|
|
164
179
|
|
|
165
|
-
|
|
166
|
-
# (no prior assistant messages in the conversation history).
|
|
167
|
-
def first_message_in_session?(messages)
|
|
168
|
-
messages.none? do |m|
|
|
169
|
-
role = (m[:role] || m["role"]).to_s
|
|
170
|
-
# "model" is used by some providers (e.g. Gemini) as an alias for "assistant"
|
|
171
|
-
role == "assistant" || role == "model"
|
|
180
|
+
parts << text
|
|
172
181
|
end
|
|
173
|
-
end
|
|
174
182
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def build_context_prefix(messages)
|
|
179
|
-
# Find the first "user" message that looks like creative context
|
|
180
|
-
# (typically starts with "Creative:\n")
|
|
181
|
-
context_msg = messages.find do |m|
|
|
182
|
-
role = m[:role] || m["role"]
|
|
183
|
+
# 3. Chat history (prior user/assistant exchanges)
|
|
184
|
+
formatted.each do |m|
|
|
185
|
+
role = (m[:role] || m["role"]).to_s
|
|
183
186
|
text = extract_message_text(m)
|
|
184
|
-
|
|
185
|
-
end
|
|
187
|
+
next unless text.present?
|
|
186
188
|
|
|
187
|
-
|
|
189
|
+
# Skip context messages (already included above)
|
|
190
|
+
next if text.match?(/\A(Creative|Context Creative|Referenced Creative)\s*\(/)
|
|
188
191
|
|
|
189
|
-
|
|
192
|
+
case role
|
|
193
|
+
when "user"
|
|
194
|
+
parts << text
|
|
195
|
+
when "assistant", "model"
|
|
196
|
+
parts << "[Assistant]: #{text}"
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
parts.join("\n\n")
|
|
190
201
|
end
|
|
191
202
|
|
|
192
203
|
def extract_message_text(message)
|
|
@@ -205,6 +216,12 @@ module CollavreOpenclaw
|
|
|
205
216
|
Array(parts).filter_map { |part| part[:image] || part["image"] }
|
|
206
217
|
end
|
|
207
218
|
|
|
219
|
+
def extract_ws_attachments(messages)
|
|
220
|
+
Array(messages).flat_map do |message|
|
|
221
|
+
extract_image_sources(message).filter_map { |source| encode_image_source_for_ws(source) }
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
208
225
|
def encode_image_source(source)
|
|
209
226
|
if defined?(ActiveStorage) && source.is_a?(ActiveStorage::Blob)
|
|
210
227
|
data = Base64.strict_encode64(source.download)
|
|
@@ -239,6 +256,43 @@ module CollavreOpenclaw
|
|
|
239
256
|
nil
|
|
240
257
|
end
|
|
241
258
|
|
|
259
|
+
def encode_image_source_for_ws(source)
|
|
260
|
+
if defined?(ActiveStorage) && source.is_a?(ActiveStorage::Blob)
|
|
261
|
+
{
|
|
262
|
+
type: "image",
|
|
263
|
+
mimeType: source.content_type,
|
|
264
|
+
fileName: source.filename.to_s,
|
|
265
|
+
content: Base64.strict_encode64(source.download)
|
|
266
|
+
}
|
|
267
|
+
elsif source.respond_to?(:download)
|
|
268
|
+
blob = source.respond_to?(:blob) ? source.blob : source
|
|
269
|
+
return nil unless blob
|
|
270
|
+
|
|
271
|
+
{
|
|
272
|
+
type: "image",
|
|
273
|
+
mimeType: blob.content_type,
|
|
274
|
+
fileName: blob.filename.to_s,
|
|
275
|
+
content: Base64.strict_encode64(blob.download)
|
|
276
|
+
}
|
|
277
|
+
elsif source.is_a?(String) && source.match?(%r{^https?://})
|
|
278
|
+
nil
|
|
279
|
+
elsif source.is_a?(String)
|
|
280
|
+
return nil unless File.exist?(source)
|
|
281
|
+
|
|
282
|
+
{
|
|
283
|
+
type: "image",
|
|
284
|
+
mimeType: Marcel::MimeType.for(Pathname.new(source)),
|
|
285
|
+
fileName: File.basename(source),
|
|
286
|
+
content: Base64.strict_encode64(File.binread(source))
|
|
287
|
+
}
|
|
288
|
+
else
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
rescue StandardError => e
|
|
292
|
+
Rails.logger.warn("[CollavreOpenclaw] Failed to encode WS image attachment: #{e.message}")
|
|
293
|
+
nil
|
|
294
|
+
end
|
|
295
|
+
|
|
242
296
|
# ─────────────────────────────────────────────
|
|
243
297
|
# HTTP transport (fallback)
|
|
244
298
|
# ─────────────────────────────────────────────
|
|
@@ -281,7 +335,7 @@ module CollavreOpenclaw
|
|
|
281
335
|
# Format: agent:<agent_id>:collavre:<user_id>:creative:<id>:topic:<id>
|
|
282
336
|
def build_session_key
|
|
283
337
|
creative_id = extract_id(@context, :creative) || @context[:creative_id]
|
|
284
|
-
topic_id = @context[:thread_id] || @context[:topic_id]
|
|
338
|
+
topic_id = @context[:thread_id] || @context[:topic_id] || infer_topic_id
|
|
285
339
|
agent_id = extract_agent_id_from_email || "main"
|
|
286
340
|
|
|
287
341
|
parts = [ "agent", agent_id, "collavre", @user.id ]
|
|
@@ -318,7 +372,7 @@ module CollavreOpenclaw
|
|
|
318
372
|
|
|
319
373
|
creative_id = extract_id(@context, :creative) || @context[:creative_id]
|
|
320
374
|
comment_id = extract_id(@context, :comment) || @context[:comment_id]
|
|
321
|
-
topic_id = @context[:thread_id] || @context[:topic_id]
|
|
375
|
+
topic_id = @context[:thread_id] || @context[:topic_id] || infer_topic_id
|
|
322
376
|
|
|
323
377
|
callback = callback_url
|
|
324
378
|
if callback.present? && creative_id.present?
|
|
@@ -539,6 +593,15 @@ module CollavreOpenclaw
|
|
|
539
593
|
@user.email.split("@").first
|
|
540
594
|
end
|
|
541
595
|
|
|
596
|
+
# Infer topic_id from the comment object in context when not explicitly provided.
|
|
597
|
+
# AiAgentService passes :comment (the reply or original comment) which carries topic_id.
|
|
598
|
+
def infer_topic_id
|
|
599
|
+
comment = @context[:comment]
|
|
600
|
+
return comment.topic_id if comment.respond_to?(:topic_id) && comment.topic_id.present?
|
|
601
|
+
|
|
602
|
+
nil
|
|
603
|
+
end
|
|
604
|
+
|
|
542
605
|
def default_url_options
|
|
543
606
|
options = Rails.application.config.action_mailer.default_url_options || {}
|
|
544
607
|
|
|
@@ -22,6 +22,20 @@ module CollavreOpenclaw
|
|
|
22
22
|
COMPLETED_RUN_COOLDOWN = 5 # seconds to suppress late-arriving events for completed runs
|
|
23
23
|
SEEN_EVENT_TTL = 30 # seconds to remember (runId, seq) pairs for dedup
|
|
24
24
|
|
|
25
|
+
# WebSocket close codes → reconnection policy
|
|
26
|
+
# :reconnect — schedule reconnect with exponential backoff
|
|
27
|
+
# :fatal — permanent failure (auth, forbidden), don't retry, propagate error
|
|
28
|
+
# :normal — clean shutdown, no reconnect, no error
|
|
29
|
+
CLOSE_POLICIES = {
|
|
30
|
+
1000 => :normal, # Normal closure
|
|
31
|
+
1001 => :reconnect, # Going away (server shutting down)
|
|
32
|
+
1006 => :reconnect, # Abnormal closure (network issue)
|
|
33
|
+
1008 => :fatal, # Policy violation (likely auth)
|
|
34
|
+
1011 => :reconnect, # Internal server error
|
|
35
|
+
4001 => :fatal, # Auth failure (OpenClaw)
|
|
36
|
+
4003 => :fatal # Forbidden (OpenClaw)
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
25
39
|
attr_reader :user, :state
|
|
26
40
|
|
|
27
41
|
# States: :disconnected, :connecting, :connected, :reconnecting
|
|
@@ -39,8 +53,7 @@ module CollavreOpenclaw
|
|
|
39
53
|
@proactive_handler = nil
|
|
40
54
|
@reconnect_attempts = 0
|
|
41
55
|
@last_activity_at = nil
|
|
42
|
-
@
|
|
43
|
-
@tick_timer = nil
|
|
56
|
+
@rpc_run_registrations = {} # RPC request_id → run_queue (for EM-thread runId registration)
|
|
44
57
|
end
|
|
45
58
|
|
|
46
59
|
def connected?
|
|
@@ -52,7 +65,6 @@ module CollavreOpenclaw
|
|
|
52
65
|
def connect!
|
|
53
66
|
return if connected?
|
|
54
67
|
|
|
55
|
-
initiator = false
|
|
56
68
|
waiter_queue = nil
|
|
57
69
|
|
|
58
70
|
@connect_mutex.synchronize do
|
|
@@ -64,7 +76,6 @@ module CollavreOpenclaw
|
|
|
64
76
|
@connect_waiters << waiter_queue
|
|
65
77
|
else
|
|
66
78
|
@state = :connecting
|
|
67
|
-
initiator = true
|
|
68
79
|
end
|
|
69
80
|
end
|
|
70
81
|
|
|
@@ -126,7 +137,6 @@ module CollavreOpenclaw
|
|
|
126
137
|
def disconnect!
|
|
127
138
|
@state = :disconnected
|
|
128
139
|
EmReactor.next_tick do
|
|
129
|
-
cancel_tick_timer!
|
|
130
140
|
@ws&.close
|
|
131
141
|
@ws = nil
|
|
132
142
|
end
|
|
@@ -135,16 +145,18 @@ module CollavreOpenclaw
|
|
|
135
145
|
@pending_requests.clear
|
|
136
146
|
@pending_runs.each_value { |q| q.push({ done: true }) }
|
|
137
147
|
@pending_runs.clear
|
|
148
|
+
@rpc_run_registrations.clear
|
|
138
149
|
end
|
|
139
150
|
|
|
140
151
|
# Send a chat message. Blocks and yields streaming events.
|
|
141
152
|
#
|
|
142
153
|
# @param session_key [String]
|
|
143
154
|
# @param message [String]
|
|
155
|
+
# @param attachments [Array<Hash>, nil]
|
|
144
156
|
# @param idempotency_key [String]
|
|
145
157
|
# @yield [Hash] chat events with :state, :text, :message keys
|
|
146
158
|
# @return [String, nil] final response text
|
|
147
|
-
def chat_send(session_key:, message:, idempotency_key: nil, &block)
|
|
159
|
+
def chat_send(session_key:, message:, attachments: nil, idempotency_key: nil, &block)
|
|
148
160
|
ensure_connected!
|
|
149
161
|
touch_activity!
|
|
150
162
|
|
|
@@ -156,19 +168,33 @@ module CollavreOpenclaw
|
|
|
156
168
|
# Pre-register with idempotency_key to catch early events
|
|
157
169
|
@mutex.synchronize { @pending_runs[idempotency_key] = run_queue }
|
|
158
170
|
|
|
159
|
-
# Send the RPC request to get the real runId
|
|
160
|
-
|
|
171
|
+
# Send the RPC request to get the real runId.
|
|
172
|
+
# IMPORTANT: We pass the run_queue via @rpc_run_registrations so that
|
|
173
|
+
# handle_response can register @pending_runs[actual_run_id] on the EM
|
|
174
|
+
# thread BEFORE any subsequent chat events arrive. This prevents a race
|
|
175
|
+
# condition where fast Gateway responses send events before the Rails
|
|
176
|
+
# thread can re-register with the actual runId.
|
|
177
|
+
rpc_request_id = SecureRandom.uuid
|
|
178
|
+
@mutex.synchronize { @rpc_run_registrations[rpc_request_id] = run_queue }
|
|
179
|
+
|
|
180
|
+
rpc_params = {
|
|
161
181
|
sessionKey: session_key,
|
|
162
182
|
message: message,
|
|
163
183
|
idempotencyKey: idempotency_key
|
|
164
|
-
}
|
|
184
|
+
}
|
|
185
|
+
rpc_params[:attachments] = attachments if attachments.present?
|
|
186
|
+
|
|
187
|
+
response = send_rpc("chat.send", rpc_params, request_id: rpc_request_id)
|
|
165
188
|
|
|
166
|
-
#
|
|
189
|
+
# The EM thread already registered @pending_runs[actual_run_id] in
|
|
190
|
+
# handle_response. Clean up the idempotency_key entry if a different
|
|
191
|
+
# runId was assigned.
|
|
167
192
|
actual_run_id = response&.dig(:runId) || idempotency_key
|
|
168
193
|
if actual_run_id != idempotency_key
|
|
169
194
|
@mutex.synchronize do
|
|
170
195
|
@pending_runs.delete(idempotency_key)
|
|
171
|
-
|
|
196
|
+
# Ensure runId is registered (may already be from handle_response)
|
|
197
|
+
@pending_runs[actual_run_id] ||= run_queue
|
|
172
198
|
end
|
|
173
199
|
end
|
|
174
200
|
|
|
@@ -185,14 +211,19 @@ module CollavreOpenclaw
|
|
|
185
211
|
end
|
|
186
212
|
|
|
187
213
|
# Gateway may broadcast + nodeSend the same event, causing
|
|
188
|
-
# duplicates on the same WebSocket.
|
|
214
|
+
# duplicates on the same WebSocket. Skip already-seen seqs for
|
|
215
|
+
# deltas only. Terminal events (final, error, aborted) must NEVER
|
|
216
|
+
# be skipped — they break the loop and unblock the caller.
|
|
189
217
|
seq = event[:seq]
|
|
190
|
-
|
|
218
|
+
event_state = event[:state]
|
|
219
|
+
is_terminal = event_state == "final" || event_state == "error" || event_state == "aborted"
|
|
220
|
+
|
|
221
|
+
if !is_terminal && seq && last_seq && seq <= last_seq
|
|
191
222
|
next
|
|
192
223
|
end
|
|
193
224
|
last_seq = seq if seq
|
|
194
225
|
|
|
195
|
-
case
|
|
226
|
+
case event_state
|
|
196
227
|
when "delta"
|
|
197
228
|
text = extract_event_text(event)
|
|
198
229
|
if text.present?
|
|
@@ -260,6 +291,12 @@ module CollavreOpenclaw
|
|
|
260
291
|
@proactive_handler = handler
|
|
261
292
|
end
|
|
262
293
|
|
|
294
|
+
# Register a callback invoked when the connection dies with a fatal close code
|
|
295
|
+
# (auth failure, forbidden, etc.). ConnectionManager uses this to remove dead clients.
|
|
296
|
+
def on_fatal_close(&handler)
|
|
297
|
+
@on_fatal_close = handler
|
|
298
|
+
end
|
|
299
|
+
|
|
263
300
|
# Time since last activity (for idle timeout)
|
|
264
301
|
def idle_seconds
|
|
265
302
|
return Float::INFINITY unless @last_activity_at
|
|
@@ -272,6 +309,23 @@ module CollavreOpenclaw
|
|
|
272
309
|
CollavreOpenclaw.config
|
|
273
310
|
end
|
|
274
311
|
|
|
312
|
+
# Determine reconnection policy for a WebSocket close code.
|
|
313
|
+
# Returns :reconnect, :fatal, or :normal.
|
|
314
|
+
def close_policy(code)
|
|
315
|
+
CLOSE_POLICIES[code] || (code.to_i >= 4000 ? :fatal : :reconnect)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Drain all pending requests and streaming runs with an error message.
|
|
319
|
+
# Called on fatal close to unblock waiting Rails threads.
|
|
320
|
+
def drain_pending_with_error!(message)
|
|
321
|
+
@mutex.synchronize do
|
|
322
|
+
@pending_requests.each_value { |pr| pr[:queue]&.push({ error: message }) }
|
|
323
|
+
@pending_requests.clear
|
|
324
|
+
@pending_runs.each_value { |q| q.push({ error: message }) }
|
|
325
|
+
@pending_runs.clear
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
275
329
|
def gateway_ws_url
|
|
276
330
|
url = @user.gateway_url.to_s.strip
|
|
277
331
|
return nil if url.blank?
|
|
@@ -301,8 +355,7 @@ module CollavreOpenclaw
|
|
|
301
355
|
@handshake_done = false
|
|
302
356
|
|
|
303
357
|
@ws.on :open do |_event|
|
|
304
|
-
Rails.logger.info("[CollavreOpenclaw::WS]
|
|
305
|
-
# Wait for connect.challenge from gateway
|
|
358
|
+
Rails.logger.info("[CollavreOpenclaw::WS] CONNECT gateway=#{url} state=open")
|
|
306
359
|
end
|
|
307
360
|
|
|
308
361
|
@ws.on :message do |event|
|
|
@@ -312,18 +365,26 @@ module CollavreOpenclaw
|
|
|
312
365
|
@ws.on :close do |event|
|
|
313
366
|
code = event.code
|
|
314
367
|
reason = event.reason
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
cancel_tick_timer!
|
|
368
|
+
policy = close_policy(code)
|
|
369
|
+
Rails.logger.info("[CollavreOpenclaw::WS] DISCONNECT gateway=#{url} code=#{code} reason=#{reason} policy=#{policy}")
|
|
318
370
|
|
|
319
371
|
unless @handshake_done
|
|
320
372
|
@handshake_done = true
|
|
321
373
|
@handshake_queue&.push({ error: "Connection closed during handshake (code=#{code})" })
|
|
322
374
|
end
|
|
323
375
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
376
|
+
case policy
|
|
377
|
+
when :reconnect
|
|
378
|
+
if @state == :connected || @state == :connecting
|
|
379
|
+
@state = :reconnecting
|
|
380
|
+
schedule_reconnect!
|
|
381
|
+
end
|
|
382
|
+
when :fatal
|
|
383
|
+
@state = :disconnected
|
|
384
|
+
drain_pending_with_error!("Connection closed with fatal code #{code}: #{reason}")
|
|
385
|
+
@on_fatal_close&.call(self)
|
|
386
|
+
when :normal
|
|
387
|
+
@state = :disconnected
|
|
327
388
|
end
|
|
328
389
|
end
|
|
329
390
|
end
|
|
@@ -395,10 +456,8 @@ module CollavreOpenclaw
|
|
|
395
456
|
if id == @connect_request_id && !@handshake_done
|
|
396
457
|
@handshake_done = true
|
|
397
458
|
if ok
|
|
398
|
-
@tick_interval_ms = payload&.dig(:policy, :tickIntervalMs) || 15_000
|
|
399
459
|
@state = :connected
|
|
400
460
|
@reconnect_attempts = 0
|
|
401
|
-
start_tick_timer!
|
|
402
461
|
@handshake_queue&.push({ ok: true, payload: payload })
|
|
403
462
|
else
|
|
404
463
|
error_msg = error&.dig(:message) || error.to_s || "handshake failed"
|
|
@@ -411,6 +470,19 @@ module CollavreOpenclaw
|
|
|
411
470
|
# Regular RPC response
|
|
412
471
|
pending = @mutex.synchronize { @pending_requests.delete(id) }
|
|
413
472
|
if pending
|
|
473
|
+
# If this RPC response contains a runId (chat.send response), register
|
|
474
|
+
# the run_queue under that runId NOW, on the EM thread, before any
|
|
475
|
+
# subsequent chat events arrive. This eliminates the race condition
|
|
476
|
+
# where fast events arrive before the Rails thread can re-register.
|
|
477
|
+
if ok && payload.is_a?(Hash) && payload[:runId]
|
|
478
|
+
run_queue = @mutex.synchronize { @rpc_run_registrations.delete(id) }
|
|
479
|
+
if run_queue
|
|
480
|
+
@mutex.synchronize { @pending_runs[payload[:runId]] = run_queue }
|
|
481
|
+
end
|
|
482
|
+
else
|
|
483
|
+
@mutex.synchronize { @rpc_run_registrations.delete(id) }
|
|
484
|
+
end
|
|
485
|
+
|
|
414
486
|
if ok
|
|
415
487
|
pending[:queue].push({ ok: true, payload: payload })
|
|
416
488
|
else
|
|
@@ -425,9 +497,11 @@ module CollavreOpenclaw
|
|
|
425
497
|
seq = payload[:seq]
|
|
426
498
|
|
|
427
499
|
# Dedup: Gateway sends identical events via broadcast() + nodeSendToSession().
|
|
428
|
-
#
|
|
429
|
-
|
|
430
|
-
|
|
500
|
+
# Key includes state so that a delta and final with the same seq are NOT
|
|
501
|
+
# treated as duplicates (they are different events that share a seq number).
|
|
502
|
+
state = payload[:state]
|
|
503
|
+
if run_id && seq && duplicate_chat_event?(run_id, seq, state)
|
|
504
|
+
Rails.logger.debug("[CollavreOpenclaw::WS] CHAT run=#{run_id} seq=#{seq} state=dedup_skipped")
|
|
431
505
|
return
|
|
432
506
|
end
|
|
433
507
|
|
|
@@ -439,21 +513,22 @@ module CollavreOpenclaw
|
|
|
439
513
|
run_queue.push(payload)
|
|
440
514
|
elsif recently_completed_run?(run_id)
|
|
441
515
|
# Late-arriving event for a run we already finished — suppress it
|
|
442
|
-
Rails.logger.info("[CollavreOpenclaw::WS]
|
|
516
|
+
Rails.logger.info("[CollavreOpenclaw::WS] CHAT run=#{run_id} state=late_suppressed")
|
|
443
517
|
elsif @proactive_handler
|
|
444
518
|
# Unknown run — proactive message from Gateway (cron/heartbeat)
|
|
445
|
-
Rails.logger.info("[CollavreOpenclaw::WS]
|
|
519
|
+
Rails.logger.info("[CollavreOpenclaw::WS] CHAT run=#{run_id} state=proactive")
|
|
446
520
|
@proactive_handler.call(@user, payload)
|
|
447
521
|
else
|
|
448
|
-
Rails.logger.debug("[CollavreOpenclaw::WS]
|
|
522
|
+
Rails.logger.debug("[CollavreOpenclaw::WS] CHAT run=#{run_id} state=ignored_no_handler")
|
|
449
523
|
end
|
|
450
524
|
end
|
|
451
525
|
|
|
452
|
-
# Check if this (runId, seq)
|
|
526
|
+
# Check if this (runId, seq, state) triple was already seen. Records it if new.
|
|
453
527
|
# Gateway emits each event via broadcast() AND nodeSendToSession(),
|
|
454
|
-
# delivering the identical payload
|
|
455
|
-
|
|
456
|
-
|
|
528
|
+
# delivering the identical payload twice. Including state in the key
|
|
529
|
+
# ensures a delta and final with the same seq are treated as distinct events.
|
|
530
|
+
def duplicate_chat_event?(run_id, seq, state = nil)
|
|
531
|
+
key = "#{run_id}:#{seq}:#{state}"
|
|
457
532
|
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
458
533
|
|
|
459
534
|
@mutex.synchronize do
|
|
@@ -485,38 +560,21 @@ module CollavreOpenclaw
|
|
|
485
560
|
end
|
|
486
561
|
|
|
487
562
|
def handle_tick(_payload)
|
|
488
|
-
#
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
id: SecureRandom.uuid,
|
|
492
|
-
method: "poll",
|
|
493
|
-
params: {}
|
|
494
|
-
})
|
|
495
|
-
end
|
|
496
|
-
|
|
497
|
-
def start_tick_timer!
|
|
498
|
-
cancel_tick_timer!
|
|
499
|
-
interval = @tick_interval_ms / 1000.0
|
|
500
|
-
@tick_timer = EM.add_periodic_timer(interval) do
|
|
501
|
-
# Send keepalive poll if the server hasn't sent a tick
|
|
502
|
-
send_frame({
|
|
503
|
-
type: "req",
|
|
504
|
-
id: SecureRandom.uuid,
|
|
505
|
-
method: "poll",
|
|
506
|
-
params: {}
|
|
507
|
-
})
|
|
508
|
-
end
|
|
509
|
-
end
|
|
510
|
-
|
|
511
|
-
def cancel_tick_timer!
|
|
512
|
-
@tick_timer&.cancel
|
|
513
|
-
@tick_timer = nil
|
|
563
|
+
# The tick event from Gateway is a keepalive heartbeat.
|
|
564
|
+
# Receiving it already refreshes our activity timer (via touch_activity!
|
|
565
|
+
# in handle_raw_message). No response is needed.
|
|
514
566
|
end
|
|
515
567
|
|
|
516
568
|
# Send an RPC request and block until the response.
|
|
517
569
|
# Returns the response payload.
|
|
518
|
-
|
|
519
|
-
|
|
570
|
+
#
|
|
571
|
+
# @param method [String] RPC method name
|
|
572
|
+
# @param params [Hash] RPC parameters
|
|
573
|
+
# @param request_id [String, nil] Pre-generated request ID. Used by chat_send
|
|
574
|
+
# to correlate the RPC response with the run_queue for EM-thread runId
|
|
575
|
+
# registration (see handle_response). If nil, a random UUID is generated.
|
|
576
|
+
def send_rpc(method, params, request_id: nil)
|
|
577
|
+
request_id ||= SecureRandom.uuid
|
|
520
578
|
queue = Queue.new
|
|
521
579
|
|
|
522
580
|
@mutex.synchronize do
|
|
@@ -566,7 +624,8 @@ module CollavreOpenclaw
|
|
|
566
624
|
delay = config.ws_reconnect_base_delay * (2**(@reconnect_attempts - 1))
|
|
567
625
|
delay = [ delay, 60 ].min # Cap at 60 seconds
|
|
568
626
|
|
|
569
|
-
|
|
627
|
+
url = gateway_ws_url
|
|
628
|
+
Rails.logger.info("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} attempt=#{@reconnect_attempts}/#{max} delay=#{delay}s")
|
|
570
629
|
|
|
571
630
|
EM.add_timer(delay) do
|
|
572
631
|
next if @state == :disconnected # User explicitly disconnected
|
|
@@ -580,13 +639,13 @@ module CollavreOpenclaw
|
|
|
580
639
|
EM.add_timer(config.ws_connect_timeout) do
|
|
581
640
|
unless @handshake_done
|
|
582
641
|
@handshake_done = true
|
|
583
|
-
Rails.logger.warn("[CollavreOpenclaw::WS]
|
|
642
|
+
Rails.logger.warn("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} state=handshake_timeout")
|
|
584
643
|
@ws&.close
|
|
585
644
|
schedule_reconnect!
|
|
586
645
|
end
|
|
587
646
|
end
|
|
588
647
|
rescue => e
|
|
589
|
-
Rails.logger.error("[CollavreOpenclaw::WS]
|
|
648
|
+
Rails.logger.error("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} state=fail reason=#{e.message}")
|
|
590
649
|
schedule_reconnect!
|
|
591
650
|
end
|
|
592
651
|
end
|
|
@@ -33,7 +33,7 @@ module CollavreOpenclaw
|
|
|
33
33
|
@ws_reconnect_max = ENV.fetch("OPENCLAW_WS_RECONNECT_MAX", 10).to_i
|
|
34
34
|
@ws_reconnect_base_delay = ENV.fetch("OPENCLAW_WS_RECONNECT_BASE", 1).to_f
|
|
35
35
|
@ws_connect_timeout = ENV.fetch("OPENCLAW_WS_CONNECT_TIMEOUT", 10).to_i
|
|
36
|
-
@transport = ENV.fetch("OPENCLAW_TRANSPORT", "
|
|
36
|
+
@transport = ENV.fetch("OPENCLAW_TRANSPORT", "auto")
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
# Legacy accessor for backward compatibility
|