datagrout-conduit 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 61761ad0b8b189c1e4f13c4b552fc22811f4658f3144b84a336c42ed21477224
4
- data.tar.gz: 1417cb0c91e1740bc1bb9db884b1df57e00966a7ec7076d2f28c8ba8f205b5b0
3
+ metadata.gz: d3a08c2cb30deefe712f0083b473838a1ec6b578c1b7decbcc53ac14aca4cffb
4
+ data.tar.gz: ea88b13e4a5998164c4dfaa1b71d448e5d498b694b8de312449a8c82d4daf555
5
5
  SHA512:
6
- metadata.gz: 0a23d1ea87b87d76d82b7ec546f590a90f1682ce23d701ff3e0b7e248f1eba8adcb6f374499ba16cb5ce97e1d2e7778f2e294979c98881af843d3c12ad748e30
7
- data.tar.gz: 529763eb59b00754846a84198d6d5d0037a7e0360e0482c119fe0fcafae939b4b7a563f112b215e6bc1f9e1ae7a96c221d5e3274e736be53c8b549ec0c4a2ed8
6
+ metadata.gz: 70c40acb181dcc382395c454e769e26fede6798a32dc2dab7797ffaf66b1936dd0e89cf767f2f9f16a5e668ad1ceff9dfd3d2a22d03db34bcb146ab1eef78ba6
7
+ data.tar.gz: 3c2cf29896ae08b63876e60e8807ccb7ea85118fdb5d422aaf9b94e4ef3aa76345786b42bb0406f1c8df1b938d3755cf78a84fd3bf9728f5ecc4fd54a6c7b737
data/README.md CHANGED
@@ -9,13 +9,13 @@ Connect to remote MCP and JSONRPC servers, invoke tools, discover capabilities w
9
9
  Add to your Gemfile:
10
10
 
11
11
  ```ruby
12
- gem "datagrout-conduit", "~> 0.1"
12
+ gem "datagrout-conduit", "~> 0.5.0"
13
13
  ```
14
14
 
15
15
  Or install directly:
16
16
 
17
17
  ```sh
18
- gem install datagrout-conduit
18
+ gem install datagrout-conduit -v 0.5.0
19
19
  ```
20
20
 
21
21
  ## Quick Start
@@ -168,7 +168,49 @@ client = DatagroutConduit::Client.new(
168
168
  )
169
169
  ```
170
170
 
171
- Both transports send JSON-RPC 2.0 requests via HTTP POST. MCP uses the MCP Streamable HTTP framing. Both configure Faraday SSL with mTLS client certificates when an identity is present.
171
+ ### WebSocket (`datagrout-jsonrpc.v1`)
172
+
173
+ ```ruby
174
+ client = DatagroutConduit::Client.new(
175
+ url: "wss://gateway.datagrout.ai/servers/{uuid}/ws",
176
+ auth: { bearer: "your-token" },
177
+ transport: :websocket
178
+ )
179
+ client.connect
180
+ ```
181
+
182
+ Bidirectional push over a single `wss://` connection using `websocket-driver ~> 0.7` (the library underlying Rails ActionCable — no EventMachine dependency). A background `Thread` runs the read loop; shared state is protected by a `Mutex`.
183
+
184
+ #### Push subscriptions
185
+
186
+ ```ruby
187
+ # Subscribe — returns a Subscription with recv + each (Enumerable)
188
+ sub = client.subscribe("agents.my-agent-id.events")
189
+
190
+ # Block on the next event
191
+ event = sub.recv(timeout: 30)
192
+ puts "#{event.event}: #{event.data.inspect}"
193
+
194
+ # Or iterate until the subscription is closed
195
+ sub.each do |event|
196
+ puts "#{event.event}: #{event.data.inspect}"
197
+ end
198
+
199
+ # Unsubscribe when done
200
+ client.unsubscribe(sub)
201
+ ```
202
+
203
+ Supported topics:
204
+
205
+ | Topic | Fires when |
206
+ |-------|-----------|
207
+ | `agents.<agent_id>.events` | Agent lifecycle events (plan started, IC completed, grounding failed, …) |
208
+ | `tools.<tool_name>.results` | A specific tool call completes |
209
+ | `tasks.<task_id>.*` | Long-running background task transitions |
210
+ | `flows.<flow_id>.*` | `flow.into` progress and completion |
211
+ | `governor.<server_uuid>` | Governor percept events (file change, schedule, webhook) |
212
+
213
+ **Reconnection**: after a disconnect, `send_request` and `subscribe` raise `DatagroutConduit::NotInitializedError`. Call `client.connect` again and re-subscribe — subscriptions do not survive reconnects in v0.4.
172
214
 
173
215
  ## Standard MCP Methods
174
216
 
@@ -13,14 +13,33 @@ module DatagroutConduit
13
13
 
14
14
  attr_reader :transport, :server_info, :use_intelligent_interface
15
15
 
16
+ # Subscribe to a server-push topic over a WebSocket transport.
17
+ # Returns a {DatagroutConduit::Transport::Ws::Subscription}.
18
+ # Raises RuntimeError when transport is not :websocket.
19
+ def subscribe(topic)
20
+ raise "subscribe() requires transport: :websocket" unless @transport.is_a?(Transport::Ws)
21
+
22
+ @transport.subscribe(topic)
23
+ end
24
+
25
+ # Cancel a push subscription.
26
+ # Accepts a Subscription object or a subscription ID string.
27
+ def unsubscribe(subscription)
28
+ raise "unsubscribe() requires transport: :websocket" unless @transport.is_a?(Transport::Ws)
29
+
30
+ @transport.unsubscribe(subscription)
31
+ end
32
+
16
33
  def initialize(url:, auth: {}, transport: :mcp, identity: nil, identity_dir: nil,
17
- use_intelligent_interface: nil, max_retries: 3, logger: nil, disable_mtls: false)
34
+ use_intelligent_interface: nil, max_retries: 3, logger: nil,
35
+ identity_auto: false, disable_mtls: false)
18
36
  @url = url
19
37
  @auth = auth
20
38
  @transport_mode = transport
21
39
  @identity = identity
22
40
  @identity_dir = identity_dir
23
- @disable_mtls = disable_mtls
41
+ @identity_auto = identity_auto
42
+ @disable_mtls = disable_mtls # deprecated, kept for backward compat
24
43
  @max_retries = max_retries
25
44
  @initialized = false
26
45
  @server_info = nil
@@ -87,6 +106,38 @@ module DatagroutConduit
87
106
  bootstrap_identity(url: url, auth_token: token, name: name, identity_dir: identity_dir)
88
107
  end
89
108
 
109
+ # Bootstrap by performing the autonomous DG onramp flow.
110
+ #
111
+ # The all-in-one flow: onramp (no prior credentials required) →
112
+ # OAuth token exchange → mTLS identity registration and persistence.
113
+ #
114
+ # On subsequent runs the saved mTLS identity is auto-discovered and
115
+ # no credentials are needed.
116
+ #
117
+ # @param opts [DatagroutConduit::Onramp::OnrampOptions]
118
+ # @param url [String, nil] MCP server URL; required when +opts.mcp_url+ is absent
119
+ # @param name [String] human-readable identity label
120
+ # @param identity_dir [String, nil] custom identity storage directory
121
+ # @return [Client] unconnected client; call +#connect+ before use
122
+ def self.bootstrap_onramp(opts:, url: nil, name: "conduit-client", identity_dir: nil)
123
+ dir = identity_dir || Registration.default_identity_dir || File.join(Dir.home, ".conduit")
124
+
125
+ # Fast path: existing valid identity.
126
+ identity = Identity.try_discover(override_dir: dir)
127
+ if identity && !identity.needs_rotation?
128
+ raise ArgumentError, "'url' must be provided when an existing identity is reused" if url.nil?
129
+ return new(url: url, identity: identity)
130
+ end
131
+
132
+ # Slow path: full onramp flow.
133
+ creds, token = Onramp.register_and_exchange(opts)
134
+
135
+ mcp_url = creds.mcp_url || url
136
+ raise ArgumentError, "'url' must be provided when mcp_url is absent from onramp response" if mcp_url.nil?
137
+
138
+ bootstrap_identity(url: mcp_url, auth_token: token, name: name, identity_dir: identity_dir)
139
+ end
140
+
90
141
  def connect
91
142
  @transport.connect
92
143
 
@@ -389,15 +440,19 @@ module DatagroutConduit
389
440
  # transport, transparently rewrite the path to the DG JSONRPC endpoint.
390
441
  rpc_url = @url.end_with?("/mcp") ? @url.sub(%r{/mcp$}, "/rpc") : @url
391
442
  Transport::JsonRpc.new(url: rpc_url, auth: @auth, identity: @identity)
443
+ when :websocket, "websocket"
444
+ ws_url = @url
445
+ .sub(/\Ahttps:\/\//, "wss://")
446
+ .sub(/\Ahttp:\/\//, "ws://")
447
+ Transport::Ws.new(url: ws_url, auth: @auth, identity: @identity)
392
448
  else
393
- raise ConfigError, "Unknown transport: #{@transport_mode}. Use :mcp or :jsonrpc."
449
+ raise ConfigError, "Unknown transport: #{@transport_mode}. Use :mcp, :jsonrpc, or :websocket."
394
450
  end
395
451
  end
396
452
 
397
453
  def resolve_identity!
398
454
  return if @identity
399
- return if @disable_mtls
400
- return unless @is_dg
455
+ return unless @identity_auto
401
456
 
402
457
  @identity = Identity.try_discover(override_dir: @identity_dir)
403
458
  end
@@ -67,31 +67,30 @@ module DatagroutConduit
67
67
  # Walk the auto-discovery chain and return the first identity found,
68
68
  # or nil if nothing is available.
69
69
  def self.try_discover(override_dir: nil)
70
- # 1. Override directory
70
+ # When an explicit directory is given, scope search to that dir only.
71
71
  if override_dir
72
- id = try_load_from_dir(override_dir)
73
- return id if id
72
+ return try_load_from_dir(override_dir)
74
73
  end
75
74
 
76
- # 2. Environment variables (individual cert/key PEMs)
75
+ # 1. Environment variables (individual cert/key PEMs)
77
76
  id = from_env
78
77
  return id if id
79
78
 
80
- # 3. CONDUIT_IDENTITY_DIR env var
79
+ # 2. CONDUIT_IDENTITY_DIR env var
81
80
  identity_dir = ENV["CONDUIT_IDENTITY_DIR"]
82
81
  if identity_dir && !identity_dir.empty?
83
82
  id = try_load_from_dir(identity_dir)
84
83
  return id if id
85
84
  end
86
85
 
87
- # 4. ~/.conduit/
86
+ # 3. ~/.conduit/
88
87
  home = ENV["HOME"] || ENV["USERPROFILE"]
89
88
  if home
90
89
  id = try_load_from_dir(File.join(home, ".conduit"))
91
90
  return id if id
92
91
  end
93
92
 
94
- # 5. .conduit/ relative to cwd
93
+ # 4. .conduit/ relative to cwd
95
94
  id = try_load_from_dir(File.join(Dir.pwd, ".conduit"))
96
95
  return id if id
97
96
 
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module DatagroutConduit
7
+ # Autonomous agent self-registration (onramp) for DataGrout.
8
+ #
9
+ # The onramp flow lets a machine intelligence register itself with DG
10
+ # without a human in the loop, using only plain HTTP JSON — no MCP client
11
+ # required.
12
+ #
13
+ # == Flow
14
+ #
15
+ # 1. POST to +/onramp+ with agent identity metadata (no auth).
16
+ # 2. DG returns a short-lived +session_token+ (5 minutes).
17
+ # 3. POST to +/onramp/complete+ with +Authorization: Bearer <session_token>+.
18
+ # 4. DG issues provisional +client_id+ + +client_secret+ (restricted scopes).
19
+ #
20
+ # == Example
21
+ #
22
+ # opts = DatagroutConduit::Onramp::OnrampOptions.new(
23
+ # gateway: "https://app.datagrout.ai",
24
+ # agent_name: "my-research-agent",
25
+ # agent_type: "claude-sonnet-4-6"
26
+ # )
27
+ # client = DatagroutConduit::Client.bootstrap_onramp(opts: opts, url: nil)
28
+ module Onramp
29
+ # Options for the autonomous agent onramp flow.
30
+ OnrampOptions = Struct.new(
31
+ :gateway,
32
+ :agent_name,
33
+ :agent_type,
34
+ :intended_use,
35
+ :access_code,
36
+ keyword_init: true
37
+ )
38
+
39
+ # Provisional credentials returned by the DG onramp complete endpoint.
40
+ #
41
+ # Store +client_id+ and +client_secret+ securely — the secret is shown
42
+ # exactly once and cannot be recovered after this point.
43
+ OnrampCredentials = Struct.new(
44
+ :client_id,
45
+ :client_secret,
46
+ :token_url,
47
+ :scopes,
48
+ :expires_in,
49
+ :rpc_url,
50
+ :mcp_url,
51
+ keyword_init: true
52
+ )
53
+
54
+ class OnrampError < StandardError; end
55
+
56
+ # Perform the onramp handshake and return provisional OAuth credentials.
57
+ #
58
+ # Low-level entry point. Most callers should use
59
+ # {DatagroutConduit::Client.bootstrap_onramp} instead.
60
+ #
61
+ # @param opts [OnrampOptions]
62
+ # @return [OnrampCredentials]
63
+ def self.register_only(opts)
64
+ register(opts)
65
+ end
66
+
67
+ # Perform the full onramp handshake and OAuth token exchange.
68
+ #
69
+ # @param opts [OnrampOptions]
70
+ # @return [Array(OnrampCredentials, String)] credentials and access token
71
+ def self.register_and_exchange(opts)
72
+ creds = register(opts)
73
+ token = exchange_token(creds)
74
+ [creds, token]
75
+ end
76
+
77
+ # @api private
78
+ def self.register(opts)
79
+ base = opts.gateway.chomp("/")
80
+
81
+ body = { agent_name: opts.agent_name }
82
+ body[:agent_type] = opts.agent_type if opts.agent_type
83
+ body[:intended_use] = opts.intended_use if opts.intended_use
84
+ body[:access_code] = opts.access_code if opts.access_code
85
+
86
+ conn = build_conn
87
+
88
+ init_resp = conn.post("#{base}/onramp") do |req|
89
+ req.headers["Content-Type"] = "application/json"
90
+ req.body = JSON.generate(body)
91
+ end
92
+
93
+ raise OnrampError, "onramp init rejected (HTTP #{init_resp.status}): #{init_resp.body}" \
94
+ unless init_resp.success?
95
+
96
+ init_data = parse_body(init_resp.body)
97
+ session_token = init_data["session_token"]
98
+
99
+ complete_resp = conn.post("#{base}/onramp/complete") do |req|
100
+ req.headers["Authorization"] = "Bearer #{session_token}"
101
+ end
102
+
103
+ raise OnrampError, "onramp complete rejected (HTTP #{complete_resp.status}): #{complete_resp.body}" \
104
+ unless complete_resp.success?
105
+
106
+ data = parse_body(complete_resp.body)
107
+
108
+ OnrampCredentials.new(
109
+ client_id: data["client_id"],
110
+ client_secret: data["client_secret"],
111
+ token_url: data["token_url"],
112
+ scopes: data["scopes"] || [],
113
+ expires_in: data["expires_in"] || 0,
114
+ rpc_url: data["rpc_url"],
115
+ mcp_url: data["mcp_url"]
116
+ )
117
+ end
118
+ private_class_method :register
119
+
120
+ # @api private
121
+ def self.exchange_token(creds)
122
+ conn = Faraday.new do |f|
123
+ f.request :url_encoded
124
+ f.response :json, content_type: /\bjson$/
125
+ f.adapter Faraday.default_adapter
126
+ end
127
+
128
+ resp = conn.post(creds.token_url, {
129
+ grant_type: "client_credentials",
130
+ client_id: creds.client_id,
131
+ client_secret: creds.client_secret
132
+ })
133
+
134
+ raise OnrampError, "token exchange failed (HTTP #{resp.status}): #{resp.body}" \
135
+ unless resp.success?
136
+
137
+ data = parse_body(resp.body)
138
+ data["access_token"]
139
+ end
140
+ private_class_method :exchange_token
141
+
142
+ def self.build_conn
143
+ Faraday.new do |f|
144
+ f.response :json, content_type: /\bjson$/
145
+ f.adapter Faraday.default_adapter
146
+ end
147
+ end
148
+ private_class_method :build_conn
149
+
150
+ def self.parse_body(body)
151
+ body.is_a?(String) ? JSON.parse(body) : body
152
+ end
153
+ private_class_method :parse_body
154
+ end
155
+ end
@@ -0,0 +1,478 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "websocket/driver"
4
+ require "socket"
5
+ require "openssl"
6
+ require "uri"
7
+ require "thread"
8
+ require "json"
9
+ require "securerandom"
10
+ require "base64"
11
+ require "timeout"
12
+
13
+ module DatagroutConduit
14
+ module Transport
15
+ # WebSocket transport for datagrout-jsonrpc.v1.
16
+ #
17
+ # Manages a single wss:// connection with concurrent JSON-RPC request
18
+ # multiplexing and server-push subscriptions. Uses a background thread
19
+ # for frame reading; callers block on Thread::Queue for responses.
20
+ #
21
+ # Usage:
22
+ # ws = DatagroutConduit::Transport::Ws.new(
23
+ # url: "wss://gateway.datagrout.ai/servers/<uuid>/ws",
24
+ # auth: { bearer: "token" }
25
+ # )
26
+ # ws.connect
27
+ # result = ws.send_request("tools/list")
28
+ # sub = ws.subscribe("agents.my-agent.events")
29
+ # event = sub.recv
30
+ # ws.unsubscribe(sub)
31
+ # ws.disconnect
32
+ class Ws
33
+ SUBPROTOCOL = "datagrout-jsonrpc.v1"
34
+
35
+ # ── Subscription ─────────────────────────────────────────────────────────
36
+
37
+ # Per-subscription event stream delivered via a thread-safe Queue.
38
+ # Call recv to block until the next event, or iterate with each.
39
+ class Subscription
40
+ attr_reader :sub_id, :topic
41
+
42
+ def initialize(sub_id, topic)
43
+ @sub_id = sub_id
44
+ @topic = topic
45
+ @queue = ::Thread::Queue.new
46
+ @closed = false
47
+ end
48
+
49
+ # Block until the next event arrives.
50
+ # Returns nil and raises StopIteration on close when iterating via each.
51
+ # @param timeout [Numeric, nil] optional timeout in seconds; returns nil on expiry
52
+ def recv(timeout: nil)
53
+ event =
54
+ if timeout
55
+ Timeout.timeout(timeout) { @queue.pop }
56
+ else
57
+ @queue.pop
58
+ end
59
+
60
+ raise StopIteration if event.nil?
61
+
62
+ event
63
+ rescue Timeout::Error
64
+ nil
65
+ end
66
+
67
+ # Iterate over events until the subscription is closed.
68
+ def each(&block)
69
+ loop do
70
+ event = @queue.pop
71
+ break if event.nil?
72
+ block.call(event)
73
+ end
74
+ end
75
+
76
+ include Enumerable
77
+
78
+ def closed?
79
+ @closed
80
+ end
81
+
82
+ # @api private
83
+ def _enqueue(event)
84
+ @queue.push(event) unless @closed
85
+ end
86
+
87
+ # @api private
88
+ def _close
89
+ return if @closed
90
+
91
+ @closed = true
92
+ @queue.push(nil)
93
+ end
94
+ end
95
+
96
+ # Value object for a push notification delivered to a subscription.
97
+ SubscriptionEvent = Struct.new(:subscription, :event, :data, keyword_init: true)
98
+
99
+ # ── Construction ─────────────────────────────────────────────────────────
100
+
101
+ def initialize(url:, auth: {}, identity: nil)
102
+ @url = url
103
+ @auth = normalize_auth(auth)
104
+ @identity = identity
105
+ @mutex = Mutex.new
106
+ @write_mutex = Mutex.new
107
+
108
+ @pending = {} # id => RequestFuture
109
+ @pending_subscribe = {} # id => { topic:, future: }
110
+ @subscriptions = {} # sub_id => [Subscription, ...]
111
+ @next_id = 0
112
+
113
+ @io = nil
114
+ @driver = nil
115
+ @read_thread = nil
116
+ @connected = false
117
+ end
118
+
119
+ # ── Public API ────────────────────────────────────────────────────────────
120
+
121
+ # Establish the WebSocket connection.
122
+ # Blocks until the server-side handshake completes (up to 10 s).
123
+ def connect
124
+ uri = URI.parse(@url)
125
+ @io = open_socket(uri)
126
+
127
+ adapter = SocketAdapter.new(@url, @io)
128
+ @driver = WebSocket::Driver.client(adapter, protocols: [SUBPROTOCOL])
129
+
130
+ build_upgrade_headers.each { |k, v| @driver.set_header(k, v) }
131
+
132
+ handshake_q = ::Thread::Queue.new
133
+
134
+ @driver.on(:open) { handshake_q.push(nil) unless @connected }
135
+ @driver.on(:message) { |e| handle_message(e.data) }
136
+ @driver.on(:close) { handle_disconnect }
137
+ @driver.on(:error) { |e| handshake_q.push(e.message) unless @connected }
138
+
139
+ @driver.start
140
+ @read_thread = Thread.new { read_loop }
141
+ @read_thread.abort_on_exception = false
142
+ @read_thread.name = "conduit-ws-reader"
143
+
144
+ err = Timeout.timeout(10) { handshake_q.pop }
145
+ raise ConnectionError, "WebSocket handshake failed: #{err}" if err
146
+
147
+ @connected = true
148
+ self
149
+ rescue Timeout::Error
150
+ cleanup_socket
151
+ raise ConnectionError, "WebSocket connection timed out"
152
+ rescue ConnectionError
153
+ raise
154
+ rescue => e
155
+ cleanup_socket
156
+ raise ConnectionError, "WebSocket connect error: #{e.message}"
157
+ end
158
+
159
+ # Close the connection and fail all pending requests.
160
+ def disconnect
161
+ @mutex.synchronize { @connected = false }
162
+ fail_all_pending(:disconnected)
163
+ cleanup_socket
164
+ self
165
+ end
166
+
167
+ def connected?
168
+ @connected
169
+ end
170
+
171
+ # Send a JSON-RPC request and block until the response arrives.
172
+ # Pass id: nil to fire a notification (no response expected).
173
+ # Returns the result value, or raises McpError on RPC-level error.
174
+ def send_request(method, params = nil, id: :auto)
175
+ ensure_connected!
176
+
177
+ # id: nil means fire-and-forget notification (no id field, no response wait)
178
+ if id.nil?
179
+ frame = { "jsonrpc" => "2.0", "method" => method }
180
+ frame["params"] = params if params
181
+ write_frame(frame)
182
+ return { "result" => {} }
183
+ end
184
+
185
+ req_id = mint_id
186
+ future = RequestFuture.new
187
+ @mutex.synchronize { @pending[req_id] = future }
188
+
189
+ write_frame(build_request(req_id, method, params))
190
+
191
+ result, value = future.wait
192
+ if result == :ok
193
+ { "result" => value }
194
+ else
195
+ raise McpError.new(code: -1, message: value.to_s, data: nil)
196
+ end
197
+ end
198
+
199
+ # Subscribe to a dotted-namespace topic.
200
+ # Returns a Subscription that delivers events via recv / each.
201
+ def subscribe(topic)
202
+ ensure_connected!
203
+
204
+ req_id = mint_id
205
+ future = RequestFuture.new
206
+ @mutex.synchronize { @pending_subscribe[req_id] = { topic: topic, future: future } }
207
+
208
+ write_frame(build_request(req_id, "subscribe", { "topic" => topic }))
209
+
210
+ result, value = future.wait
211
+ if result == :ok
212
+ sub_id = value.is_a?(Hash) ? (value["subscription"] || req_id) : req_id
213
+ sub = Subscription.new(sub_id, topic)
214
+ @mutex.synchronize do
215
+ @subscriptions[sub_id] ||= []
216
+ @subscriptions[sub_id] << sub
217
+ end
218
+ sub
219
+ else
220
+ raise McpError.new(code: -1, message: value.to_s, data: nil)
221
+ end
222
+ end
223
+
224
+ # Cancel a push subscription locally and notify the server.
225
+ # Accepts a Subscription object or a subscription ID string.
226
+ def unsubscribe(subscription)
227
+ sub_id = subscription.is_a?(Subscription) ? subscription.sub_id : subscription.to_s
228
+
229
+ subs = @mutex.synchronize { @subscriptions.delete(sub_id) || [] }
230
+ subs.each(&:_close)
231
+
232
+ if @connected
233
+ req_id = mint_id
234
+ write_frame(build_request(req_id, "unsubscribe", { "subscription" => sub_id }))
235
+ end
236
+
237
+ :ok
238
+ end
239
+
240
+ private
241
+
242
+ # ── Socket adapter for websocket-driver ──────────────────────────────────
243
+
244
+ class SocketAdapter
245
+ attr_reader :url
246
+
247
+ def initialize(url, io)
248
+ @url = url
249
+ @io = io
250
+ end
251
+
252
+ def write(data)
253
+ @io.write(data)
254
+ data.bytesize
255
+ end
256
+ end
257
+
258
+ # ── Request future ────────────────────────────────────────────────────────
259
+
260
+ class RequestFuture
261
+ def initialize
262
+ @queue = ::Thread::Queue.new
263
+ end
264
+
265
+ def wait(timeout: 30)
266
+ Timeout.timeout(timeout) { @queue.pop }
267
+ rescue Timeout::Error
268
+ [:error, "Request timed out after #{timeout}s"]
269
+ end
270
+
271
+ def resolve(value)
272
+ @queue.push([:ok, value])
273
+ end
274
+
275
+ def reject(reason)
276
+ @queue.push([:error, reason])
277
+ end
278
+ end
279
+
280
+ # ── Socket creation ───────────────────────────────────────────────────────
281
+
282
+ def open_socket(uri)
283
+ host = uri.host
284
+ port = uri.port || (uri.scheme == "wss" ? 443 : 80)
285
+
286
+ tcp = TCPSocket.new(host, port)
287
+ tcp.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
288
+
289
+ if uri.scheme == "wss"
290
+ ctx = build_ssl_context
291
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
292
+ ssl.hostname = host
293
+ ssl.sync_close = true
294
+ ssl.connect
295
+ ssl
296
+ else
297
+ tcp
298
+ end
299
+ end
300
+
301
+ def build_ssl_context
302
+ ctx = OpenSSL::SSL::SSLContext.new
303
+ ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
304
+
305
+ if @identity
306
+ ctx.cert = @identity.openssl_cert
307
+ ctx.key = @identity.openssl_key
308
+ if @identity.ca_pem
309
+ store = OpenSSL::X509::Store.new
310
+ store.add_cert(@identity.openssl_ca)
311
+ ctx.cert_store = store
312
+ end
313
+ end
314
+
315
+ ctx
316
+ end
317
+
318
+ # ── Upgrade headers ───────────────────────────────────────────────────────
319
+
320
+ def build_upgrade_headers
321
+ headers = {}
322
+
323
+ case @auth[:type]
324
+ when :bearer
325
+ headers["Authorization"] = "Bearer #{@auth[:token]}"
326
+ when :api_key
327
+ headers["X-API-Key"] = @auth[:key]
328
+ when :basic
329
+ encoded = Base64.strict_encode64("#{@auth[:username]}:#{@auth[:password]}")
330
+ headers["Authorization"] = "Basic #{encoded}"
331
+ when :oauth
332
+ token = @auth[:provider].get_token
333
+ headers["Authorization"] = "Bearer #{token}"
334
+ end
335
+
336
+ headers
337
+ end
338
+
339
+ # ── Read loop ─────────────────────────────────────────────────────────────
340
+
341
+ def read_loop
342
+ loop do
343
+ data = @io.readpartial(4096)
344
+ @driver.parse(data)
345
+ rescue EOFError, IOError, Errno::ECONNRESET, Errno::EPIPE
346
+ handle_disconnect
347
+ break
348
+ rescue StandardError
349
+ handle_disconnect
350
+ break
351
+ end
352
+ end
353
+
354
+ # ── Message routing ───────────────────────────────────────────────────────
355
+
356
+ def handle_message(raw)
357
+ msg = JSON.parse(raw)
358
+
359
+ str_id = msg["id"]&.to_s
360
+
361
+ if str_id && (entry = @mutex.synchronize { @pending_subscribe.delete(str_id) })
362
+ future = entry[:future]
363
+ if msg["error"]
364
+ future.reject(msg.dig("error", "message") || "Subscribe failed")
365
+ else
366
+ future.resolve(msg["result"] || {})
367
+ end
368
+
369
+ elsif str_id && (future = @mutex.synchronize { @pending.delete(str_id) })
370
+ if msg["error"]
371
+ future.reject(msg.dig("error", "message") || "RPC error")
372
+ else
373
+ future.resolve(msg["result"])
374
+ end
375
+
376
+ elsif msg["method"] == "notification"
377
+ route_notification(msg["params"] || {})
378
+ end
379
+ rescue JSON::ParserError
380
+ # Silently discard malformed frames
381
+ end
382
+
383
+ def route_notification(params)
384
+ sub_id = params["subscription"]
385
+ return unless sub_id.is_a?(String)
386
+
387
+ subs = @mutex.synchronize { @subscriptions[sub_id] }
388
+ return unless subs
389
+
390
+ event = SubscriptionEvent.new(
391
+ subscription: sub_id,
392
+ event: params["event"] || "",
393
+ data: params["data"]
394
+ )
395
+
396
+ subs.each { |sub| sub._enqueue(event) }
397
+ end
398
+
399
+ def handle_disconnect
400
+ was_connected = @mutex.synchronize do
401
+ old = @connected
402
+ @connected = false
403
+ old
404
+ end
405
+
406
+ fail_all_pending(:disconnected) if was_connected
407
+ end
408
+
409
+ def fail_all_pending(reason)
410
+ pending, pending_sub, subs = @mutex.synchronize do
411
+ p = @pending.dup
412
+ ps = @pending_subscribe.dup
413
+ s = @subscriptions.dup
414
+ @pending.clear
415
+ @pending_subscribe.clear
416
+ @subscriptions.clear
417
+ [p, ps, s]
418
+ end
419
+
420
+ pending.each_value { |f| f.reject(reason) }
421
+ pending_sub.each_value { |entry| entry[:future].reject(reason) }
422
+ subs.each_value { |list| list.each(&:_close) }
423
+ end
424
+
425
+ def cleanup_socket
426
+ @write_mutex.synchronize do
427
+ @driver = nil
428
+ end
429
+ @read_thread&.kill
430
+ @read_thread = nil
431
+ @io&.close rescue nil
432
+ @io = nil
433
+ end
434
+
435
+ # ── Helpers ───────────────────────────────────────────────────────────────
436
+
437
+ def ensure_connected!
438
+ raise NotInitializedError, "WebSocket not connected. Call connect() first." unless @connected
439
+ end
440
+
441
+ def mint_id
442
+ @mutex.synchronize do
443
+ @next_id += 1
444
+ "ws-#{@next_id}"
445
+ end
446
+ end
447
+
448
+ def build_request(id, method, params)
449
+ body = { "jsonrpc" => "2.0", "id" => id, "method" => method }
450
+ body["params"] = params if params
451
+ body
452
+ end
453
+
454
+ def write_frame(data)
455
+ json = JSON.generate(data)
456
+ @write_mutex.synchronize { @driver&.text(json) }
457
+ end
458
+
459
+ def normalize_auth(auth)
460
+ return { type: :none } if auth.nil? || auth.empty?
461
+
462
+ auth = auth.transform_keys(&:to_sym) if auth.is_a?(Hash)
463
+
464
+ if auth[:bearer]
465
+ { type: :bearer, token: auth[:bearer] }
466
+ elsif auth[:api_key]
467
+ { type: :api_key, key: auth[:api_key] }
468
+ elsif auth[:basic]
469
+ { type: :basic, username: auth[:basic][:username], password: auth[:basic][:password] }
470
+ elsif auth[:oauth] || auth[:provider]
471
+ { type: :oauth, provider: auth[:oauth] || auth[:provider] }
472
+ else
473
+ { type: :none }
474
+ end
475
+ end
476
+ end
477
+ end
478
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DatagroutConduit
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end
@@ -8,9 +8,11 @@ require_relative "datagrout_conduit/types"
8
8
  require_relative "datagrout_conduit/identity"
9
9
  require_relative "datagrout_conduit/oauth"
10
10
  require_relative "datagrout_conduit/registration"
11
+ require_relative "datagrout_conduit/onramp"
11
12
  require_relative "datagrout_conduit/transport/base"
12
13
  require_relative "datagrout_conduit/transport/mcp"
13
14
  require_relative "datagrout_conduit/transport/jsonrpc"
15
+ require_relative "datagrout_conduit/transport/ws"
14
16
  require_relative "datagrout_conduit/namespaces/prism"
15
17
  require_relative "datagrout_conduit/namespaces/logic"
16
18
  require_relative "datagrout_conduit/namespaces/warden"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: datagrout-conduit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - DataGrout
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-23 00:00:00.000000000 Z
11
+ date: 2026-05-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -52,6 +52,20 @@ dependencies:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: websocket-driver
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.7'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.7'
55
69
  - !ruby/object:Gem::Dependency
56
70
  name: minitest
57
71
  requirement: !ruby/object:Gem::Requirement
@@ -115,10 +129,12 @@ files:
115
129
  - lib/datagrout_conduit/namespaces/prism.rb
116
130
  - lib/datagrout_conduit/namespaces/warden.rb
117
131
  - lib/datagrout_conduit/oauth.rb
132
+ - lib/datagrout_conduit/onramp.rb
118
133
  - lib/datagrout_conduit/registration.rb
119
134
  - lib/datagrout_conduit/transport/base.rb
120
135
  - lib/datagrout_conduit/transport/jsonrpc.rb
121
136
  - lib/datagrout_conduit/transport/mcp.rb
137
+ - lib/datagrout_conduit/transport/ws.rb
122
138
  - lib/datagrout_conduit/types.rb
123
139
  - lib/datagrout_conduit/version.rb
124
140
  homepage: https://github.com/DataGrout/conduit-sdk