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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/exe/gitbroker +10 -0
- data/lib/gitbroker/agents/base.rb +48 -0
- data/lib/gitbroker/agents/claude.rb +92 -0
- data/lib/gitbroker/agents/codex.rb +72 -0
- data/lib/gitbroker/agents.rb +18 -0
- data/lib/gitbroker/api.rb +53 -0
- data/lib/gitbroker/cable_client.rb +213 -0
- data/lib/gitbroker/cli.rb +199 -0
- data/lib/gitbroker/config.rb +88 -0
- data/lib/gitbroker/daemon.rb +166 -0
- data/lib/gitbroker/explain_verifier.rb +31 -0
- data/lib/gitbroker/gh_check.rb +22 -0
- data/lib/gitbroker/git_context.rb +105 -0
- data/lib/gitbroker/hook.rb +50 -0
- data/lib/gitbroker/hooks_installer.rb +114 -0
- data/lib/gitbroker/launcher.rb +62 -0
- data/lib/gitbroker/lifecycle.rb +114 -0
- data/lib/gitbroker/local_relay.rb +100 -0
- data/lib/gitbroker/login.rb +61 -0
- data/lib/gitbroker/neutralizer.rb +80 -0
- data/lib/gitbroker/process_scanner.rb +103 -0
- data/lib/gitbroker/repo_scanner.rb +50 -0
- data/lib/gitbroker/run_script.rb +58 -0
- data/lib/gitbroker/runner.rb +41 -0
- data/lib/gitbroker/service_installer.rb +145 -0
- data/lib/gitbroker/skill_installer.rb +30 -0
- data/lib/gitbroker/task_files.rb +90 -0
- data/lib/gitbroker/task_runner.rb +258 -0
- data/lib/gitbroker/url_handler_installer.rb +87 -0
- data/lib/gitbroker/version.rb +5 -0
- data/lib/gitbroker/worktree.rb +93 -0
- data/lib/gitbroker.rb +39 -0
- data/plugin/.claude-plugin/plugin.json +5 -0
- data/plugin/skills/gitbroker/SKILL.md +36 -0
- data/plugin/skills/gitbroker-explain/SKILL.md +141 -0
- 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,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
|