gitbroker 0.3.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.
Files changed (38) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/gitbroker +10 -0
  4. data/lib/gitbroker/agents/base.rb +48 -0
  5. data/lib/gitbroker/agents/claude.rb +92 -0
  6. data/lib/gitbroker/agents/codex.rb +72 -0
  7. data/lib/gitbroker/agents.rb +18 -0
  8. data/lib/gitbroker/api.rb +53 -0
  9. data/lib/gitbroker/cable_client.rb +213 -0
  10. data/lib/gitbroker/cli.rb +199 -0
  11. data/lib/gitbroker/config.rb +88 -0
  12. data/lib/gitbroker/daemon.rb +166 -0
  13. data/lib/gitbroker/explain_verifier.rb +31 -0
  14. data/lib/gitbroker/gh_check.rb +22 -0
  15. data/lib/gitbroker/git_context.rb +105 -0
  16. data/lib/gitbroker/hook.rb +50 -0
  17. data/lib/gitbroker/hooks_installer.rb +114 -0
  18. data/lib/gitbroker/launcher.rb +62 -0
  19. data/lib/gitbroker/lifecycle.rb +114 -0
  20. data/lib/gitbroker/local_relay.rb +100 -0
  21. data/lib/gitbroker/login.rb +61 -0
  22. data/lib/gitbroker/neutralizer.rb +80 -0
  23. data/lib/gitbroker/process_scanner.rb +103 -0
  24. data/lib/gitbroker/repo_scanner.rb +50 -0
  25. data/lib/gitbroker/run_script.rb +58 -0
  26. data/lib/gitbroker/runner.rb +41 -0
  27. data/lib/gitbroker/service_installer.rb +145 -0
  28. data/lib/gitbroker/skill_installer.rb +30 -0
  29. data/lib/gitbroker/task_files.rb +90 -0
  30. data/lib/gitbroker/task_runner.rb +258 -0
  31. data/lib/gitbroker/url_handler_installer.rb +87 -0
  32. data/lib/gitbroker/version.rb +5 -0
  33. data/lib/gitbroker/worktree.rb +93 -0
  34. data/lib/gitbroker.rb +39 -0
  35. data/plugin/.claude-plugin/plugin.json +5 -0
  36. data/plugin/skills/gitbroker/SKILL.md +36 -0
  37. data/plugin/skills/gitbroker-explain/SKILL.md +141 -0
  38. metadata +95 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 31100d13953d9e191985df3879c813d7c099bc1b63bb4131e1525c14678f6a0e
4
+ data.tar.gz: 62d14854178753254f2625997bd99e30239f4f040a2a6c77a74fa42eed3a8f8d
5
+ SHA512:
6
+ metadata.gz: 8660d0d75f555a54866a226a4b082e007036901151ba2af069e4eadb6d8c30e5ede3826d3dc815ed749f18c950dfdc68055a23b60d80841828515eb3f2663491
7
+ data.tar.gz: 5e52cc5c07ad6400dd8e9eac3bb9ceef06ea1dd0a353b693e006f3865fb0f4c685776c41144ddba9b2e2a369d525f0c6ba6e3ce31fb06261dd843181559d0fee
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GitBroker
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/exe/gitbroker ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/gitbroker"
5
+
6
+ begin
7
+ exit Gitbroker::CLI.new(ARGV).run
8
+ rescue Interrupt
9
+ exit 130
10
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Gitbroker
6
+ module Agents
7
+ class Base
8
+ TOKEN_ENV = "GITBROKER_TASK_TOKEN"
9
+ MAX_PATH = 100
10
+
11
+ def initialize(config:, worktree:)
12
+ @config = config
13
+ @worktree = worktree.to_s.chomp("/")
14
+ end
15
+
16
+ # The run's lines for the user's log: what the model said and the tools it called. Subclasses override.
17
+ def parse_log(_line) = []
18
+
19
+ def env(token, task_id) = { TOKEN_ENV => token, "GITBROKER_TASK_ID" => task_id }
20
+
21
+ private
22
+
23
+ # Spec §5.2: progress carries tool names and paths inside the worktree only.
24
+ def step(verb, path = nil, noun: "a file")
25
+ path = path.is_a?(String) ? path : ""
26
+ relative = path.start_with?("#{@worktree}/") ? path.delete_prefix("#{@worktree}/") : nil
27
+ return "#{verb} #{noun}" if relative.nil? || relative.empty? || relative.include?("..") || relative.match?(/[[:cntrl:]]/)
28
+
29
+ "#{verb} #{relative[0, MAX_PATH]}"
30
+ end
31
+
32
+ def parse_json(line)
33
+ event = JSON.parse(line)
34
+ event.is_a?(Hash) ? event : nil
35
+ rescue JSON::ParserError
36
+ nil
37
+ end
38
+
39
+ # The URL is written into a TOML override (Codex), so it must be a plain http(s) URL.
40
+ def mcp_url(mcp_config)
41
+ url = JSON.parse(File.read(mcp_config)).dig("mcpServers", "gitbroker", "url").to_s
42
+ raise Error, "Invalid GitBroker MCP URL #{url.inspect} in #{mcp_config}" unless url.match?(%r{\Ahttps?://[^\s"'\\]+\z})
43
+
44
+ url
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitbroker
4
+ module Agents
5
+ # Claude Code. Autonomous: `claude -p … --output-format stream-json --verbose` (spec §4.2 step 5); the programmatic
6
+ # credit pool pays for it (spec §2). Terminal: interactive `claude`.
7
+ class Claude < Base
8
+ PLUGIN_DIR = File.join(Gitbroker::ROOT, "plugin")
9
+ READ_ONLY_ARGS = [ "--permission-mode", "dontAsk", "--permission-prompts", "none",
10
+ "--allowedTools", "Read,Grep,Glob,mcp__gitbroker__*",
11
+ "--disallowedTools", "Bash,Edit,Write,NotebookEdit,WebFetch,WebSearch" ].freeze
12
+ READS = %w[Read NotebookRead].freeze
13
+ EDITS = %w[Edit MultiEdit Write NotebookEdit].freeze
14
+
15
+ def initialize(config:, worktree:, plugin_dir: PLUGIN_DIR)
16
+ super(config:, worktree:)
17
+ @plugin_dir = plugin_dir
18
+ end
19
+
20
+ def name = "claude_code"
21
+
22
+ def autonomous_command(task, mcp_config:)
23
+ args = task["read_only"] ? READ_ONLY_ARGS : @config.claude_args
24
+ [ @config.claude_command, "-p", task.fetch("prompt"), "--output-format", "stream-json", "--verbose", *args,
25
+ "--mcp-config", mcp_config, *strict(task), "--plugin-dir", @plugin_dir ]
26
+ end
27
+
28
+ def terminal_command(task, mcp_config:)
29
+ [ @config.claude_command, "--mcp-config", mcp_config, *strict(task), "--plugin-dir", @plugin_dir ]
30
+ end
31
+
32
+ def parse_stream(line)
33
+ event = parse_json(line) or return nil
34
+
35
+ case event["type"]
36
+ when "system" then Progress.new(step: "starting", session_id: event["session_id"]) if event["subtype"] == "init"
37
+ when "assistant"
38
+ tool = Array(event.dig("message", "content")).reverse.find { _1.is_a?(Hash) && _1["type"] == "tool_use" }
39
+ tool && Progress.new(step: step_for(tool["name"].to_s, tool["input"].is_a?(Hash) ? tool["input"] : {}), session_id: nil)
40
+ when "result"
41
+ Result.new(cost_usd: event["total_cost_usd"], text: event["result"].to_s, error: event["is_error"] == true)
42
+ end
43
+ end
44
+
45
+ # Assistant text and thinking, each tool call (a name and a path or a short command), and the final answer. A tool's
46
+ # result is the file contents it read, which stay on the machine (spec §5.2).
47
+ def parse_log(line)
48
+ event = parse_json(line) or return []
49
+
50
+ case event["type"]
51
+ when "assistant" then Array(event.dig("message", "content")).filter_map { log_block(_1) }
52
+ when "result" then event["result"].to_s.strip.empty? ? [] : [ Entry.new(kind: event["is_error"] == true ? "error" : "result", text: event["result"].to_s) ]
53
+ else []
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def log_block(block)
60
+ return unless block.is_a?(Hash)
61
+
62
+ case block["type"]
63
+ when "text" then Entry.new(kind: "text", text: block["text"].to_s) unless block["text"].to_s.strip.empty?
64
+ when "thinking" then Entry.new(kind: "thinking", text: block["thinking"].to_s) unless block["thinking"].to_s.strip.empty?
65
+ when "tool_use" then Entry.new(kind: "tool", text: tool_line(block["name"].to_s, block["input"].is_a?(Hash) ? block["input"] : {}))
66
+ end
67
+ end
68
+
69
+ def tool_line(tool, input)
70
+ detail = input["file_path"] || input["notebook_path"] || input["pattern"] || input["command"] || input["path"]
71
+ detail = relative_path(detail) if detail.is_a?(String)
72
+ [ tool.gsub(/[^\w-]/, "")[0, 40], (detail.to_s.gsub(/[[:cntrl:]]/, " ")[0, 200] if detail.is_a?(String)) ].compact.join(" ")
73
+ end
74
+
75
+ def relative_path(value) = value.start_with?("#{@worktree}/") ? value.delete_prefix("#{@worktree}/") : value
76
+
77
+ def strict(task) = task["untrusted"] ? [ "--strict-mcp-config" ] : []
78
+
79
+ def step_for(tool, input)
80
+ case tool
81
+ when *READS then step("reading", input["file_path"])
82
+ when *EDITS then step("editing", input["file_path"] || input["notebook_path"])
83
+ when "Bash" then "running a command"
84
+ when "Grep", "Glob" then "searching"
85
+ when /\Amcp__gitbroker__(\w+)\z/ then "calling #{Regexp.last_match(1)}"
86
+ when "TodoWrite" then "planning"
87
+ else "using #{tool.gsub(/[^\w-]/, "")[0, 40]}"
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitbroker
4
+ module Agents
5
+ # Codex. Autonomous: `codex exec --json` in a workspace-write sandbox with network (spec §4.2 step 5); the MCP
6
+ # server is configured for this run only with -c overrides. Codex reports no cost (counted as unknown).
7
+ # Read-only kinds use `--sandbox read-only`: Codex still runs shell commands there (read-only filesystem, no network),
8
+ # so it is not a no-exec mode. It also loads the user's own MCP servers (no --strict-mcp-config), which is why
9
+ # TaskRunner never runs Codex headless on an untrusted head.
10
+ class Codex < Base
11
+ READ_ONLY_ARGS = %w[--sandbox read-only].freeze
12
+
13
+ def name = "codex"
14
+
15
+ def autonomous_command(task, mcp_config:)
16
+ args = task["read_only"] ? READ_ONLY_ARGS : @config.codex_args
17
+ [ @config.codex_command, "exec", "--json", *args, *mcp_overrides(mcp_config), task.fetch("prompt") ]
18
+ end
19
+
20
+ def terminal_command(_task, mcp_config:) = [ @config.codex_command, *mcp_overrides(mcp_config) ]
21
+
22
+ def parse_stream(line)
23
+ event = parse_json(line) or return nil
24
+
25
+ case event["type"]
26
+ when "thread.started" then Progress.new(step: "starting", session_id: event["thread_id"])
27
+ when "item.started", "item.completed" then item(event["item"].is_a?(Hash) ? event["item"] : {})
28
+ when "turn.completed" then Result.new(cost_usd: nil, text: @last_message.to_s, error: false)
29
+ when "turn.failed", "error" then Result.new(cost_usd: nil, text: @last_message.to_s, error: true)
30
+ end
31
+ end
32
+
33
+ def parse_log(line)
34
+ event = parse_json(line) or return []
35
+ return [] unless %w[item.completed].include?(event["type"]) && event["item"].is_a?(Hash)
36
+
37
+ item = event["item"]
38
+ case item["type"]
39
+ when "agent_message" then item["text"].to_s.strip.empty? ? [] : [ Entry.new(kind: "text", text: item["text"].to_s) ]
40
+ when "reasoning" then item["text"].to_s.strip.empty? ? [] : [ Entry.new(kind: "thinking", text: item["text"].to_s) ]
41
+ when "command_execution" then [ Entry.new(kind: "tool", text: "Ran #{item["command"].to_s.gsub(/[[:cntrl:]]/, " ")[0, 200]}") ]
42
+ when "file_change" then [ Entry.new(kind: "tool", text: step("edited", first_change_path(item["changes"]))) ]
43
+ when "mcp_tool_call" then [ Entry.new(kind: "tool", text: "Called #{item["tool"].to_s.gsub(/[^\w]/, "")[0, 40]}") ]
44
+ else []
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def item(item)
51
+ case item["type"]
52
+ when "command_execution" then Progress.new(step: "running a command", session_id: nil)
53
+ when "file_change" then Progress.new(step: step("editing", first_change_path(item["changes"])), session_id: nil)
54
+ when "mcp_tool_call" then Progress.new(step: "calling #{item["tool"].to_s.gsub(/[^\w]/, "")[0, 40]}", session_id: nil)
55
+ when "agent_message"
56
+ @last_message = item["text"]
57
+ nil
58
+ end
59
+ end
60
+
61
+ def first_change_path(changes)
62
+ change = changes.is_a?(Array) ? changes.first : nil
63
+ change.is_a?(Hash) ? change["path"] : nil
64
+ end
65
+
66
+ def mcp_overrides(mcp_config)
67
+ [ "-c", %(mcp_servers.gitbroker.url="#{mcp_url(mcp_config)}"),
68
+ "-c", %(mcp_servers.gitbroker.env_http_headers={ "x-task-token" = "#{TOKEN_ENV}" }) ]
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gitbroker
4
+ # The user's own coding agents, run headless (autonomous) or interactively (terminal). Spec §4.2 steps 5–7.
5
+ module Agents
6
+ Progress = Data.define(:step, :session_id)
7
+ Result = Data.define(:cost_usd, :text, :error)
8
+ # One line of the run for the user's own log (kind: text, thinking, tool, error or result). Tool results (file
9
+ # contents) are never an entry.
10
+ Entry = Data.define(:kind, :text)
11
+
12
+ autoload :Base, File.expand_path("agents/base", __dir__)
13
+ autoload :Claude, File.expand_path("agents/claude", __dir__)
14
+ autoload :Codex, File.expand_path("agents/codex", __dir__)
15
+
16
+ def self.for(config, worktree:) = (config.agent == "codex" ? Codex : Claude).new(config:, worktree:)
17
+ end
18
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Gitbroker
8
+ # JSON POST client for GitBroker's companion endpoints (bearer device or task token).
9
+ class Api
10
+ class HttpError < Error
11
+ attr_reader :status, :body
12
+
13
+ def initialize(status, body)
14
+ @status = status
15
+ @body = body.is_a?(Hash) ? body : {}
16
+ super("HTTP #{status}: #{@body["error"] || body}")
17
+ end
18
+ end
19
+
20
+ def initialize(server:, token: nil)
21
+ @server = server.to_s.chomp("/")
22
+ @token = token
23
+ end
24
+
25
+ def post(path, body = {})
26
+ uri = URI("#{@server}#{path}")
27
+ request = Net::HTTP::Post.new(uri)
28
+ request["Content-Type"] = "application/json"
29
+ request["Accept"] = "application/json"
30
+ request["User-Agent"] = "gitbroker/#{VERSION}"
31
+ request["Authorization"] = "Bearer #{@token}" if @token
32
+ request.body = JSON.generate(body)
33
+
34
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
35
+ http.request(request)
36
+ end
37
+ parsed = parse(response.body)
38
+ raise HttpError.new(response.code.to_i, parsed) unless response.is_a?(Net::HTTPSuccess)
39
+
40
+ parsed
41
+ end
42
+
43
+ private
44
+
45
+ def parse(body)
46
+ return {} if body.to_s.strip.empty?
47
+
48
+ JSON.parse(body)
49
+ rescue JSON::ParserError
50
+ { "error" => body.to_s[0, 200] }
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,213 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+ require "openssl"
6
+ require "uri"
7
+ require "websocket/driver"
8
+
9
+ module Gitbroker
10
+ # One outbound WebSocket to GitBroker's Action Cable (spec §4.1: no listening ports), one thread: IO.select drives
11
+ # reads, writes, ticks, polls and staleness. `perform` may be called from any thread (task runners report from their
12
+ # own); it only queues the frame and wakes the loop, so the socket (an SSL socket in production, which is not safe
13
+ # for concurrent reads and writes) is only ever touched by the thread in `run`. One client serves one connection.
14
+ class CableClient
15
+ class Unauthorized < Error; end
16
+ class HandshakeError < Error; end
17
+
18
+ # Action Cable's JSON wire format for DeviceChannel.
19
+ class Protocol
20
+ CHANNEL = "DeviceChannel"
21
+
22
+ def identifier = JSON.generate({ channel: CHANNEL })
23
+ def subscribe = JSON.generate({ command: "subscribe", identifier: })
24
+
25
+ def perform(action, data = {})
26
+ JSON.generate({ command: "message", identifier:, data: JSON.generate(data.merge("action" => action)) })
27
+ end
28
+
29
+ def parse(raw)
30
+ message = JSON.parse(raw)
31
+ return [ :ignored ] unless message.is_a?(Hash)
32
+
33
+ case message["type"]
34
+ when "welcome" then [ :welcome ]
35
+ when "ping" then [ :ping ]
36
+ when "confirm_subscription" then [ :confirmed ]
37
+ when "reject_subscription" then [ :rejected ]
38
+ when "disconnect" then [ :disconnect, message["reason"], message["reconnect"] ]
39
+ else message["identifier"] == identifier && message["message"].is_a?(Hash) ? [ :message, message["message"] ] : [ :ignored ]
40
+ end
41
+ rescue JSON::ParserError
42
+ [ :ignored ]
43
+ end
44
+ end
45
+
46
+ SUBPROTOCOL = "actioncable-v1-json"
47
+ STALE_AFTER = 20
48
+ TICK_EVERY = 30
49
+ POLL_EVERY = 1
50
+ SELECT_TIMEOUT = 1
51
+ CONNECT_TIMEOUT = 10
52
+ READ_CHUNK = 16_384
53
+
54
+ SocketAdapter = Struct.new(:url, :io) do
55
+ def write(data) = io.write(data)
56
+ end
57
+
58
+ def initialize(url:, token:, origin:, protocol: Protocol.new)
59
+ @url = url
60
+ @token = token
61
+ @origin = origin
62
+ @protocol = protocol
63
+ @outbox = Queue.new
64
+ @wake_reader, @wake_writer = IO.pipe
65
+ end
66
+
67
+ def on_subscribed(&block) = @on_subscribed = block
68
+ def on_message(&block) = @on_message = block
69
+ def on_tick(&block) = @on_tick = block
70
+ def on_poll(&block) = @on_poll = block
71
+
72
+ def perform(action, data = {})
73
+ @outbox << @protocol.perform(action, data)
74
+ wake
75
+ end
76
+
77
+ def stop
78
+ @stopped = true
79
+ wake
80
+ end
81
+
82
+ def run
83
+ @stopped = false
84
+ @welcomed = false
85
+ @subscribed = false
86
+ @io = open_socket
87
+ @driver = build_driver
88
+ @driver.start
89
+ @started = @last_ping = @last_tick = monotonic
90
+ @last_poll = 0.0
91
+ pump
92
+ final_flush
93
+ ensure
94
+ @io.close if @io && !@io.closed?
95
+ [ @wake_reader, @wake_writer ].each(&:close)
96
+ end
97
+
98
+ private
99
+
100
+ def build_driver
101
+ driver = WebSocket::Driver.client(SocketAdapter.new(@url, @io), protocols: [ SUBPROTOCOL ])
102
+ driver.set_header("Authorization", "Bearer #{@token}")
103
+ driver.set_header("Origin", @origin)
104
+ driver.on(:message) { |event| dispatch(event.data) }
105
+ driver.on(:close) { @stopped = true }
106
+ driver.on(:error) { |event| raise HandshakeError, event.message unless @welcomed }
107
+ driver
108
+ end
109
+
110
+ def pump
111
+ until @stopped
112
+ break if read == :eof
113
+
114
+ flush
115
+ stale!
116
+ tick
117
+ poll
118
+ end
119
+ end
120
+
121
+ def read
122
+ readable = @io.respond_to?(:pending) && @io.pending.positive? ? [ @io ] : IO.select([ @io, @wake_reader ], nil, nil, SELECT_TIMEOUT)&.first
123
+ return unless readable
124
+
125
+ @wake_reader.read_nonblock(1024, exception: false) if readable.include?(@wake_reader)
126
+ return unless readable.include?(@io)
127
+
128
+ chunk = @io.read_nonblock(READ_CHUNK, exception: false)
129
+ return :eof if chunk.nil?
130
+
131
+ @driver.parse(chunk) if chunk.is_a?(String)
132
+ end
133
+
134
+ def flush
135
+ return unless @welcomed
136
+
137
+ @driver.text(@outbox.pop) until @outbox.empty?
138
+ end
139
+
140
+ # Reports queued just before a stop (a task's `finished`) still go out; a dead socket just drops them.
141
+ def final_flush
142
+ flush
143
+ rescue IOError, SystemCallError, OpenSSL::SSL::SSLError
144
+ nil
145
+ end
146
+
147
+ def stale!
148
+ if @welcomed
149
+ @stopped = true if monotonic - @last_ping > STALE_AFTER
150
+ elsif monotonic - @started > STALE_AFTER
151
+ raise HandshakeError, "No welcome from #{@url} within #{STALE_AFTER} s"
152
+ end
153
+ end
154
+
155
+ def tick
156
+ return if monotonic - @last_tick < TICK_EVERY
157
+
158
+ @last_tick = monotonic
159
+ @on_tick&.call
160
+ end
161
+
162
+ def poll
163
+ return unless @subscribed && monotonic - @last_poll >= POLL_EVERY
164
+
165
+ @last_poll = monotonic
166
+ @on_poll&.call
167
+ end
168
+
169
+ def dispatch(raw)
170
+ kind, *rest = @protocol.parse(raw)
171
+ case kind
172
+ when :welcome
173
+ @welcomed = true
174
+ @last_ping = monotonic
175
+ @driver.text(@protocol.subscribe)
176
+ when :ping then @last_ping = monotonic
177
+ when :confirmed
178
+ @subscribed = true
179
+ @on_subscribed&.call
180
+ when :rejected then raise Unauthorized, "The server rejected the device channel subscription"
181
+ when :disconnect
182
+ raise Unauthorized, "The server rejected this device token" if rest.first == "unauthorized"
183
+
184
+ @stopped = true
185
+ when :message then @on_message&.call(rest.first)
186
+ end
187
+ end
188
+
189
+ def wake
190
+ @wake_writer.write_nonblock(".", exception: false)
191
+ rescue IOError
192
+ nil
193
+ end
194
+
195
+ def open_socket
196
+ uri = URI(@url)
197
+ raise Error, "Invalid cable URL #{@url.inspect}" unless %w[ws wss].include?(uri.scheme) && uri.host
198
+
199
+ tcp = Socket.tcp(uri.host, uri.port, connect_timeout: CONNECT_TIMEOUT)
200
+ return tcp unless uri.scheme == "wss"
201
+
202
+ context = OpenSSL::SSL::SSLContext.new
203
+ context.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER, verify_hostname: true)
204
+ ssl = OpenSSL::SSL::SSLSocket.new(tcp, context)
205
+ ssl.hostname = uri.host
206
+ ssl.sync_close = true
207
+ ssl.connect
208
+ ssl
209
+ end
210
+
211
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
212
+ end
213
+ end