collavre_openclaw 0.2.2 → 0.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff70659073539f4ad6104e96ed6c9a85f4b639cf30e6bfdb6e56166d1e5cdf1d
4
- data.tar.gz: c96bff5a56ef9f74fc011d649a3be81cbba192c3a3fabbac13281d48929d2d11
3
+ metadata.gz: e7022c888e2fbe72f646eba7d022d489ff1458da405058638c0b15bc426076ec
4
+ data.tar.gz: 4b822a95fba2cba72b9ceda1d8a92f24919b8982221d8ef4ce1ed6cddea4ade0
5
5
  SHA512:
6
- metadata.gz: a3ba72b2e7fd8de71f8276858c8b81ef3767203c6769ab456d837bb9b1dc94f78c6a2584308582dcdf439e48c11d098d6d3673aecc0e34dd01775a5f9263334d
7
- data.tar.gz: eddfebd33fbce4ffd79eb80b33d7715e46706947973b4c406b7d866ff98902de24e9bd8ddaa7addf91c6fad6d071aed195f0dbd36800e59a3fbc832feb5e10cd
6
+ metadata.gz: 26df63f943760253bba51dd0667e06a6fdac8821a5982e49163d9d6114f94eed5ff110f47d4af58531e22a966f7efa669f3cacafb608c3446b9d025530b9806f
7
+ data.tar.gz: 33b8d566a78989521fe384add9bdedbdce668422c9658176c4c9cd2aa5620e31fa3fe247f7aefb82fd045ba33573bcfce391f2d3f88c8788c32a6d4737ce3cdb
@@ -2,6 +2,8 @@ module CollavreOpenclaw
2
2
  class CallbackProcessorJob < ApplicationJob
3
3
  queue_as :default
4
4
 
5
+ DEDUP_WINDOW = 5.seconds
6
+
5
7
  def perform(user_id, payload)
6
8
  @user = User.find_by(id: user_id)
7
9
  return unless @user
@@ -98,9 +100,22 @@ module CollavreOpenclaw
98
100
  return
99
101
  end
100
102
 
103
+ effective_creative = creative.effective_origin
104
+
105
+ # Dedup: skip if an identical comment was recently created
106
+ existing = Collavre::Comment
107
+ .where(user: @user, creative: effective_creative, content: content)
108
+ .where("created_at > ?", DEDUP_WINDOW.ago)
109
+ .first
110
+
111
+ if existing
112
+ Rails.logger.warn("[CollavreOpenclaw] Duplicate comment suppressed for creative #{creative_id} (existing comment #{existing.id})")
113
+ return existing
114
+ end
115
+
101
116
  # Build comment attributes
102
117
  comment_attrs = {
103
- creative: creative.effective_origin,
118
+ creative: effective_creative,
104
119
  user: @user,
105
120
  content: content,
106
121
  private: false
@@ -35,7 +35,11 @@ module CollavreOpenclaw
35
35
  end
36
36
 
37
37
  # Try WebSocket first, fall back to HTTP
38
- if websocket_available?
38
+ # Set OPENCLAW_TRANSPORT=http to force HTTP-only mode
39
+ if CollavreOpenclaw.config.transport == "http"
40
+ Rails.logger.info("[CollavreOpenclaw] Using HTTP transport (forced by config)")
41
+ chat_via_http(messages, &block)
42
+ elsif websocket_available?
39
43
  chat_via_websocket(messages, &block)
40
44
  else
41
45
  chat_via_http(messages, &block)
@@ -21,8 +21,12 @@ module CollavreOpenclaw
21
21
  # How often (seconds) to check for stale buffers.
22
22
  SWEEP_INTERVAL = 60
23
23
 
24
+ # Cooldown (seconds) to remember dispatched runIds and suppress duplicates.
25
+ DISPATCHED_RUN_TTL = 60
26
+
24
27
  def initialize
25
- @buffers = {} # runId → { text:, session_key:, connection_owner_id:, created_at: }
28
+ @buffers = {} # runId → { text:, session_key:, connection_owner_id:, created_at: }
29
+ @dispatched_runs = {} # runId → monotonic timestamp (dedup for broadcast+nodeSend duplicates)
26
30
  @mutex = Mutex.new
27
31
  @last_sweep_at = monotonic_now
28
32
  end
@@ -116,7 +120,18 @@ module CollavreOpenclaw
116
120
  def handle_final(run_id, user, session_key, payload)
117
121
  final_text = extract_text(payload)
118
122
 
119
- buffer = @mutex.synchronize { @buffers.delete(run_id) }
123
+ buffer = @mutex.synchronize do
124
+ # Check if we already dispatched this runId (broadcast+nodeSend duplicate)
125
+ if @dispatched_runs.key?(run_id)
126
+ Rails.logger.info(
127
+ "[CollavreOpenclaw::Proactive] Suppressing duplicate final for runId=#{run_id}"
128
+ )
129
+ @buffers.delete(run_id)
130
+ return
131
+ end
132
+
133
+ @buffers.delete(run_id)
134
+ end
120
135
 
121
136
  # Prefer final text (complete message) over buffered deltas (fragments).
122
137
  # Fall back to buffered deltas only when final text is empty.
@@ -128,6 +143,9 @@ module CollavreOpenclaw
128
143
 
129
144
  return unless content.present?
130
145
 
146
+ # Mark as dispatched before enqueuing to prevent duplicates
147
+ @mutex.synchronize { @dispatched_runs[run_id] = monotonic_now }
148
+
131
149
  # Use session_key from final event, falling back to buffered
132
150
  effective_session_key = session_key || buffer&.dig(:session_key)
133
151
  context = self.class.parse_session_key(effective_session_key)
@@ -166,6 +184,7 @@ module CollavreOpenclaw
166
184
 
167
185
  @last_sweep_at = now
168
186
  cutoff = now - BUFFER_TTL
187
+ dispatched_cutoff = now - DISPATCHED_RUN_TTL
169
188
 
170
189
  @mutex.synchronize do
171
190
  stale_ids = @buffers.each_with_object([]) do |(run_id, buf), ids|
@@ -179,6 +198,9 @@ module CollavreOpenclaw
179
198
  "(older than #{BUFFER_TTL}s)"
180
199
  )
181
200
  end
201
+
202
+ # Sweep expired dispatched run entries
203
+ @dispatched_runs.delete_if { |_id, ts| ts < dispatched_cutoff }
182
204
  end
183
205
  end
184
206
 
@@ -19,6 +19,8 @@ module CollavreOpenclaw
19
19
  # - Rails threads call public methods which bridge via EM.next_tick + Queue
20
20
  class WebsocketClient
21
21
  PROTOCOL_VERSION = 3
22
+ COMPLETED_RUN_COOLDOWN = 5 # seconds to suppress late-arriving events for completed runs
23
+ SEEN_EVENT_TTL = 30 # seconds to remember (runId, seq) pairs for dedup
22
24
 
23
25
  attr_reader :user, :state
24
26
 
@@ -32,6 +34,8 @@ module CollavreOpenclaw
32
34
  @connect_waiters = [] # Queues for threads waiting on in-progress connect
33
35
  @pending_requests = {} # id → { queue:, timer: }
34
36
  @pending_runs = {} # runId → Queue (for chat.send streaming)
37
+ @completed_runs = {} # runId → monotonic timestamp (cooldown for late events)
38
+ @seen_chat_events = {} # "runId:seq" → monotonic timestamp (broadcast+nodeSend dedup)
35
39
  @proactive_handler = nil
36
40
  @reconnect_attempts = 0
37
41
  @last_activity_at = nil
@@ -169,6 +173,8 @@ module CollavreOpenclaw
169
173
  end
170
174
 
171
175
  # Stream events until final/error/aborted
176
+ last_seq = nil
177
+
172
178
  loop do
173
179
  event = wait_with_timeout(run_queue, config.read_timeout, "chat response")
174
180
 
@@ -178,12 +184,27 @@ module CollavreOpenclaw
178
184
  raise ChatError, event[:error]
179
185
  end
180
186
 
187
+ # Gateway may broadcast + nodeSend the same event, causing
188
+ # duplicates on the same WebSocket. Skip already-seen seqs.
189
+ seq = event[:seq]
190
+ if seq && last_seq && seq <= last_seq
191
+ next
192
+ end
193
+ last_seq = seq if seq
194
+
181
195
  case event[:state]
182
196
  when "delta"
183
197
  text = extract_event_text(event)
184
198
  if text.present?
185
- response_text << text
186
- yield({ state: "delta", text: text }) if block_given?
199
+ # Gateway sends accumulated content (full text so far) in
200
+ # each delta event. Compute the incremental delta for callers.
201
+ delta = if response_text.present? && text.start_with?(response_text)
202
+ text[response_text.length..]
203
+ else
204
+ text
205
+ end
206
+ response_text.replace(text)
207
+ yield({ state: "delta", text: delta }) if delta.present? && block_given?
187
208
  end
188
209
  when "final"
189
210
  text = extract_event_text(event)
@@ -205,6 +226,11 @@ module CollavreOpenclaw
205
226
  @pending_runs.delete(actual_run_id) if actual_run_id
206
227
  # Also clean up idempotency_key if send_rpc failed before we got a runId
207
228
  @pending_runs.delete(idempotency_key) if idempotency_key
229
+
230
+ # Record completed runs so late-arriving events are suppressed
231
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
232
+ @completed_runs[actual_run_id] = now if actual_run_id
233
+ @completed_runs[idempotency_key] = now if idempotency_key && idempotency_key != actual_run_id
208
234
  end
209
235
  end
210
236
 
@@ -329,17 +355,18 @@ module CollavreOpenclaw
329
355
  end
330
356
 
331
357
  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]}"
358
+ # Use valid client constants from OpenClaw protocol schema.
359
+ # "gateway-client" and "backend" are appropriate for server-side integrations.
360
+ # Do NOT include partial `device` — it requires full attestation (publicKey, signature, signedAt).
334
361
 
335
362
  params = {
336
363
  minProtocol: PROTOCOL_VERSION,
337
364
  maxProtocol: PROTOCOL_VERSION,
338
365
  client: {
339
- id: "collavre",
366
+ id: "gateway-client",
340
367
  version: CollavreOpenclaw::VERSION,
341
368
  platform: "ruby",
342
- mode: "operator"
369
+ mode: "backend"
343
370
  },
344
371
  role: "operator",
345
372
  scopes: [ "operator.read", "operator.write" ],
@@ -348,10 +375,7 @@ module CollavreOpenclaw
348
375
  permissions: {},
349
376
  auth: { token: @user.llm_api_key },
350
377
  locale: "en-US",
351
- userAgent: "collavre-openclaw/#{CollavreOpenclaw::VERSION}",
352
- device: {
353
- id: device_id
354
- }
378
+ userAgent: "collavre-openclaw/#{CollavreOpenclaw::VERSION}"
355
379
  }
356
380
 
357
381
  request_id = SecureRandom.uuid
@@ -398,6 +422,14 @@ module CollavreOpenclaw
398
422
 
399
423
  def handle_chat_event(payload)
400
424
  run_id = payload[:runId]
425
+ seq = payload[:seq]
426
+
427
+ # Dedup: Gateway sends identical events via broadcast() + nodeSendToSession().
428
+ # Skip if we've already seen this (runId, seq) pair.
429
+ if run_id && seq && duplicate_chat_event?(run_id, seq)
430
+ Rails.logger.debug("[CollavreOpenclaw::WS] Skipping duplicate chat event (runId=#{run_id}, seq=#{seq})")
431
+ return
432
+ end
401
433
 
402
434
  # Check if this is a response to a pending chat.send
403
435
  run_queue = @mutex.synchronize { @pending_runs[run_id] }
@@ -405,6 +437,9 @@ module CollavreOpenclaw
405
437
  if run_queue
406
438
  # Known run — forward to the waiting thread
407
439
  run_queue.push(payload)
440
+ elsif recently_completed_run?(run_id)
441
+ # Late-arriving event for a run we already finished — suppress it
442
+ Rails.logger.info("[CollavreOpenclaw::WS] Suppressing late event for completed runId=#{run_id}")
408
443
  elsif @proactive_handler
409
444
  # Unknown run — proactive message from Gateway (cron/heartbeat)
410
445
  Rails.logger.info("[CollavreOpenclaw::WS] Proactive message received (runId=#{run_id})")
@@ -414,6 +449,41 @@ module CollavreOpenclaw
414
449
  end
415
450
  end
416
451
 
452
+ # Check if this (runId, seq) pair was already seen. Records it if new.
453
+ # Gateway emits each event via broadcast() AND nodeSendToSession(),
454
+ # delivering the identical payload (same runId + seq) twice.
455
+ def duplicate_chat_event?(run_id, seq)
456
+ key = "#{run_id}:#{seq}"
457
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
458
+
459
+ @mutex.synchronize do
460
+ # Sweep expired entries periodically
461
+ if @seen_chat_events.size > 100
462
+ @seen_chat_events.delete_if { |_k, ts| now - ts > SEEN_EVENT_TTL }
463
+ end
464
+
465
+ if @seen_chat_events.key?(key)
466
+ true
467
+ else
468
+ @seen_chat_events[key] = now
469
+ false
470
+ end
471
+ end
472
+ end
473
+
474
+ # Check if a runId was recently completed (within cooldown window).
475
+ # Also sweeps expired entries to prevent unbounded growth.
476
+ def recently_completed_run?(run_id)
477
+ @mutex.synchronize do
478
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
479
+
480
+ # Sweep expired entries
481
+ @completed_runs.delete_if { |_id, ts| now - ts > COMPLETED_RUN_COOLDOWN }
482
+
483
+ @completed_runs.key?(run_id)
484
+ end
485
+ end
486
+
417
487
  def handle_tick(_payload)
418
488
  # Respond with a tick acknowledgment (poll response)
419
489
  send_frame({
@@ -22,6 +22,9 @@ module CollavreOpenclaw
22
22
  # WebSocket connect timeout (seconds)
23
23
  attr_accessor :ws_connect_timeout
24
24
 
25
+ # Transport mode: "auto" (WebSocket first, HTTP fallback), "http" (HTTP only)
26
+ attr_accessor :transport
27
+
25
28
  def initialize
26
29
  @open_timeout = ENV.fetch("OPENCLAW_OPEN_TIMEOUT", 10).to_i
27
30
  @read_timeout = ENV.fetch("OPENCLAW_READ_TIMEOUT", 180).to_i # 3 minutes for AI responses
@@ -30,6 +33,7 @@ module CollavreOpenclaw
30
33
  @ws_reconnect_max = ENV.fetch("OPENCLAW_WS_RECONNECT_MAX", 10).to_i
31
34
  @ws_reconnect_base_delay = ENV.fetch("OPENCLAW_WS_RECONNECT_BASE", 1).to_f
32
35
  @ws_connect_timeout = ENV.fetch("OPENCLAW_WS_CONNECT_TIMEOUT", 10).to_i
36
+ @transport = ENV.fetch("OPENCLAW_TRANSPORT", "http")
33
37
  end
34
38
 
35
39
  # Legacy accessor for backward compatibility
@@ -1,3 +1,3 @@
1
1
  module CollavreOpenclaw
2
- VERSION = "0.2.2"
2
+ VERSION = "0.3.1"
3
3
  end
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.2
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre