diffbroker 0.9.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 (42) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/exe/diffbroker +10 -0
  4. data/lib/diffbroker/agent_projects.rb +35 -0
  5. data/lib/diffbroker/agents/base.rb +48 -0
  6. data/lib/diffbroker/agents/claude.rb +100 -0
  7. data/lib/diffbroker/agents/codex.rb +72 -0
  8. data/lib/diffbroker/agents.rb +18 -0
  9. data/lib/diffbroker/api.rb +59 -0
  10. data/lib/diffbroker/branch_publisher.rb +66 -0
  11. data/lib/diffbroker/cable_client.rb +213 -0
  12. data/lib/diffbroker/cli.rb +347 -0
  13. data/lib/diffbroker/config.rb +126 -0
  14. data/lib/diffbroker/daemon.rb +277 -0
  15. data/lib/diffbroker/explain_verifier.rb +55 -0
  16. data/lib/diffbroker/gh_check.rb +22 -0
  17. data/lib/diffbroker/git_context.rb +115 -0
  18. data/lib/diffbroker/hook.rb +50 -0
  19. data/lib/diffbroker/hooks_installer.rb +115 -0
  20. data/lib/diffbroker/launcher.rb +62 -0
  21. data/lib/diffbroker/lifecycle.rb +127 -0
  22. data/lib/diffbroker/local_relay.rb +100 -0
  23. data/lib/diffbroker/login.rb +68 -0
  24. data/lib/diffbroker/maintenance.rb +65 -0
  25. data/lib/diffbroker/neutralizer.rb +80 -0
  26. data/lib/diffbroker/process_scanner.rb +104 -0
  27. data/lib/diffbroker/reaper.rb +111 -0
  28. data/lib/diffbroker/repo_scanner.rb +88 -0
  29. data/lib/diffbroker/run_script.rb +62 -0
  30. data/lib/diffbroker/runner.rb +41 -0
  31. data/lib/diffbroker/service_installer.rb +179 -0
  32. data/lib/diffbroker/skill_installer.rb +30 -0
  33. data/lib/diffbroker/task_files.rb +90 -0
  34. data/lib/diffbroker/task_runner.rb +286 -0
  35. data/lib/diffbroker/url_handler_installer.rb +111 -0
  36. data/lib/diffbroker/version.rb +5 -0
  37. data/lib/diffbroker/worktree.rb +115 -0
  38. data/lib/diffbroker.rb +46 -0
  39. data/plugin/.claude-plugin/plugin.json +5 -0
  40. data/plugin/skills/diffbroker/SKILL.md +36 -0
  41. data/plugin/skills/diffbroker-explain/SKILL.md +141 -0
  42. metadata +99 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 3e54b099af52044a49465285a6e1c6433be4fbac0c9e2b76dade0717b42bb5cb
4
+ data.tar.gz: c27ba555b4cf84832fac018fabb0a32acbc13a2778a98f526db61304abd8f11f
5
+ SHA512:
6
+ metadata.gz: 71929768b4b9c14a10e969ab4ace0687849082ce27816f4e78a04593d3f177fe3f286ad007e800a7f22bd57f6798b244ba43559ccca864ffde00f89c6e9764f5
7
+ data.tar.gz: 437c9ee4dd4b481ede08fc5c080c60bdb366741d33f89da9ae2af4285ae7c8875c9dde3776683612c003dce2eb8eb7b01828a679cbcf8d82f2d1cd07d61d72da
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yuri Sidorov
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/diffbroker ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/diffbroker"
5
+
6
+ begin
7
+ exit Diffbroker::CLI.new(ARGV).run
8
+ rescue Interrupt
9
+ exit 130
10
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Diffbroker
6
+ # The directories the user's own agents already know: Claude Code lists every project it ran in (~/.claude.json),
7
+ # Codex every project it trusts (~/.codex/config.toml). They tell where the user keeps code without asking and without
8
+ # walking the disk. Only the paths are read, and they never leave the machine: the server gets what RepoScanner
9
+ # finds in them (a repository name and a path), as for any other checkout.
10
+ module AgentProjects
11
+ CODEX_PROJECT = /\A\[projects\.(?:"((?:[^"\\]|\\.)*)"|'([^']*)')\]\s*\z/
12
+
13
+ def self.paths(home: Dir.home)
14
+ (claude(File.join(home, ".claude.json")) + codex(File.join(home, ".codex", "config.toml"))).uniq
15
+ end
16
+
17
+ def self.claude(file)
18
+ projects = File.file?(file) ? JSON.parse(File.read(file))["projects"] : nil
19
+ projects.is_a?(Hash) ? projects.keys.grep(String) : []
20
+ rescue JSON::ParserError, SystemCallError
21
+ []
22
+ end
23
+
24
+ def self.codex(file)
25
+ return [] unless File.file?(file)
26
+
27
+ File.foreach(file).filter_map { |line|
28
+ match = CODEX_PROJECT.match(line.strip)
29
+ match && (match[1] ? match[1].gsub(/\\(.)/, '\\1') : match[2])
30
+ }
31
+ rescue SystemCallError
32
+ []
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Diffbroker
6
+ module Agents
7
+ class Base
8
+ TOKEN_ENV = "DIFFBROKER_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, "DIFFBROKER_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", "diffbroker", "url").to_s
42
+ raise Error, "Invalid Diff Broker 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,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Diffbroker
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(Diffbroker::ROOT, "plugin")
9
+ # A bare `Read` in --allowedTools means every file on the machine, so a prompt-injected agent could read
10
+ # ~/.aws/credentials. `Read(./**)` is the worktree only (Grep and Glob follow Read rules), the settings flag makes
11
+ # the file tools refuse anything outside the working directory in every mode (a symlink out of the worktree
12
+ # included), and the deny rules name the usual secrets. Either of the first two alone was verified to block
13
+ # reads outside the worktree, in Read, Grep and Glob.
14
+ SECRET_PATHS = %w[Read(~/.ssh/**) Read(//**/.aws/**) Read(~/.gnupg/**) Read(~/.config/**) Read(~/.netrc)
15
+ Read(~/.git-credentials) Read(//**/.env)].freeze
16
+ READ_ONLY_ARGS = [ "--permission-mode", "dontAsk", "--permission-prompts", "none",
17
+ "--allowedTools", "Read(./**),mcp__diffbroker__*",
18
+ "--disallowedTools", [ *%w[Bash Edit Write NotebookEdit WebFetch WebSearch], *SECRET_PATHS ].join(","),
19
+ "--settings", { permissions: { blockReadsOutsideWorkingDirectories: true } }.to_json ].freeze
20
+ READS = %w[Read NotebookRead].freeze
21
+ EDITS = %w[Edit MultiEdit Write NotebookEdit].freeze
22
+
23
+ def initialize(config:, worktree:, plugin_dir: PLUGIN_DIR)
24
+ super(config:, worktree:)
25
+ @plugin_dir = plugin_dir
26
+ end
27
+
28
+ def name = "claude_code"
29
+
30
+ def autonomous_command(task, mcp_config:)
31
+ args = task["read_only"] ? READ_ONLY_ARGS : @config.claude_args
32
+ [ @config.claude_command, "-p", task.fetch("prompt"), "--output-format", "stream-json", "--verbose", *args,
33
+ "--mcp-config", mcp_config, *strict(task), "--plugin-dir", @plugin_dir ]
34
+ end
35
+
36
+ def terminal_command(task, mcp_config:)
37
+ [ @config.claude_command, "--mcp-config", mcp_config, *strict(task), "--plugin-dir", @plugin_dir ]
38
+ end
39
+
40
+ def parse_stream(line)
41
+ event = parse_json(line) or return nil
42
+
43
+ case event["type"]
44
+ when "system" then Progress.new(step: "starting", session_id: event["session_id"]) if event["subtype"] == "init"
45
+ when "assistant"
46
+ tool = Array(event.dig("message", "content")).reverse.find { _1.is_a?(Hash) && _1["type"] == "tool_use" }
47
+ tool && Progress.new(step: step_for(tool["name"].to_s, tool["input"].is_a?(Hash) ? tool["input"] : {}), session_id: nil)
48
+ when "result"
49
+ Result.new(cost_usd: event["total_cost_usd"], text: event["result"].to_s, error: event["is_error"] == true)
50
+ end
51
+ end
52
+
53
+ # Assistant text and thinking, each tool call (a name and a path or a short command), and the final answer. A tool's
54
+ # result is the file contents it read, which stay on the machine (spec §5.2).
55
+ def parse_log(line)
56
+ event = parse_json(line) or return []
57
+
58
+ case event["type"]
59
+ when "assistant" then Array(event.dig("message", "content")).filter_map { log_block(_1) }
60
+ when "result" then event["result"].to_s.strip.empty? ? [] : [ Entry.new(kind: event["is_error"] == true ? "error" : "result", text: event["result"].to_s) ]
61
+ else []
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def log_block(block)
68
+ return unless block.is_a?(Hash)
69
+
70
+ case block["type"]
71
+ when "text" then Entry.new(kind: "text", text: block["text"].to_s) unless block["text"].to_s.strip.empty?
72
+ when "thinking" then Entry.new(kind: "thinking", text: block["thinking"].to_s) unless block["thinking"].to_s.strip.empty?
73
+ when "tool_use" then Entry.new(kind: "tool", text: tool_line(block["name"].to_s, block["input"].is_a?(Hash) ? block["input"] : {}))
74
+ end
75
+ end
76
+
77
+ def tool_line(tool, input)
78
+ detail = input["file_path"] || input["notebook_path"] || input["pattern"] || input["command"] || input["path"]
79
+ detail = relative_path(detail) if detail.is_a?(String)
80
+ [ tool.gsub(/[^\w-]/, "")[0, 40], (detail.to_s.gsub(/[[:cntrl:]]/, " ")[0, 200] if detail.is_a?(String)) ].compact.join(" ")
81
+ end
82
+
83
+ def relative_path(value) = value.start_with?("#{@worktree}/") ? value.delete_prefix("#{@worktree}/") : value
84
+
85
+ def strict(task) = task["untrusted"] ? [ "--strict-mcp-config" ] : []
86
+
87
+ def step_for(tool, input)
88
+ case tool
89
+ when *READS then step("reading", input["file_path"])
90
+ when *EDITS then step("editing", input["file_path"] || input["notebook_path"])
91
+ when "Bash" then "running a command"
92
+ when "Grep", "Glob" then "searching"
93
+ when /\Amcp__diffbroker__(\w+)\z/ then "calling #{Regexp.last_match(1)}"
94
+ when "TodoWrite" then "planning"
95
+ else "using #{tool.gsub(/[^\w-]/, "")[0, 40]}"
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Diffbroker
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.diffbroker.url="#{mcp_url(mcp_config)}"),
68
+ "-c", %(mcp_servers.diffbroker.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 Diffbroker
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,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Diffbroker
8
+ # JSON POST client for Diff Broker'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
+ send_request(Net::HTTP::Post, path) { |request| request.body = JSON.generate(body) }
27
+ end
28
+
29
+ def get(path) = send_request(Net::HTTP::Get, path)
30
+
31
+ private
32
+
33
+ def send_request(verb, path)
34
+ uri = URI("#{@server}#{path}")
35
+ request = verb.new(uri)
36
+ request["Content-Type"] = "application/json"
37
+ request["Accept"] = "application/json"
38
+ request["User-Agent"] = "diffbroker/#{VERSION}"
39
+ request["Authorization"] = "Bearer #{@token}" if @token
40
+ yield request if block_given?
41
+
42
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 10, read_timeout: 30) do |http|
43
+ http.request(request)
44
+ end
45
+ parsed = parse(response.body)
46
+ raise HttpError.new(response.code.to_i, parsed) unless response.is_a?(Net::HTTPSuccess)
47
+
48
+ parsed
49
+ end
50
+
51
+ def parse(body)
52
+ return {} if body.to_s.strip.empty?
53
+
54
+ JSON.parse(body)
55
+ rescue JSON::ParserError
56
+ { "error" => body.to_s[0, 200] }
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Diffbroker
4
+ # Pushes the result of an update_branch task (a rebase onto the fresh base) after the agent has exited. The agent never
5
+ # pushes it: a rebase needs a lease-guarded forced push, and the user's own Claude Code hooks refuse those inside an
6
+ # agent run. The push is pinned to the head the task started from, so it can only replace exactly the history the agent
7
+ # rebased, never a commit somebody pushed meanwhile. Nothing is pushed unless the worktree is a finished, clean,
8
+ # linear rebase on top of the fresh base.
9
+ class BranchPublisher
10
+ Result = Struct.new(:status, :message) do
11
+ def pushed? = status == :pushed
12
+ def failed? = status == :failed
13
+ end
14
+
15
+ REF = %r{\A\w[\w./-]{0,200}\z}
16
+
17
+ def initialize(dir:, head_ref:, expected_sha:, base_ref:, remote: "origin", runner: Runner.new)
18
+ @dir = dir
19
+ @head_ref = head_ref.to_s
20
+ @expected = expected_sha.to_s
21
+ @base_ref = base_ref.to_s
22
+ @remote = remote.to_s
23
+ @runner = runner
24
+ end
25
+
26
+ def publish!
27
+ return failed("No branch name to push.") unless valid_ref?(@head_ref) && valid_ref?(@base_ref) && valid_ref?(@remote)
28
+ return failed("No starting head to protect.") unless @expected.match?(/\A[0-9a-f]{7,40}\z/)
29
+ return failed("A rebase or merge is still in progress in the worktree: finish it first.") if in_progress?
30
+ return failed("The worktree has uncommitted changes.") unless git("status", "--porcelain").strip.empty?
31
+
32
+ head = git("rev-parse", "HEAD").strip
33
+ return Result.new(:nothing, "Nothing to push: the branch did not change.") if head.start_with?(@expected) || @expected.start_with?(head)
34
+
35
+ git("fetch", "--quiet", @remote, @base_ref)
36
+ unless @runner.git_success?(@dir, "merge-base", "--is-ancestor", "#{@remote}/#{@base_ref}", "HEAD")
37
+ return failed("The branch is not on top of #{@remote}/#{@base_ref}: nothing pushed.")
38
+ end
39
+ unless git("rev-list", "--merges", "#{@remote}/#{@base_ref}..HEAD").strip.empty?
40
+ return failed("The branch contains merge commits; it must be a clean rebase: nothing pushed.")
41
+ end
42
+
43
+ git("push", lease_option, @remote, "HEAD:refs/heads/#{@head_ref}")
44
+ Result.new(:pushed, "Pushed #{@head_ref} rebased onto #{@remote}/#{@base_ref}.")
45
+ rescue Error => e
46
+ failed("Push failed: #{e.message.lines.first.to_s.strip}")
47
+ end
48
+
49
+ private
50
+
51
+ # Pinned to the starting head: git refuses the push if the remote branch is anywhere else.
52
+ def lease_option = "--force-with-lease=refs/heads/#{@head_ref}:#{@expected}"
53
+
54
+ def git(*args) = @runner.git(@dir, *args)
55
+
56
+ def failed(message) = Result.new(:failed, message)
57
+
58
+ def valid_ref?(ref) = ref.match?(REF) && !ref.include?("..") && !ref.end_with?("/", ".lock")
59
+
60
+ def in_progress?
61
+ %w[rebase-merge rebase-apply MERGE_HEAD CHERRY_PICK_HEAD].any? do |name|
62
+ File.exist?(File.absolute_path(git("rev-parse", "--git-path", name).strip, @dir))
63
+ end
64
+ end
65
+ end
66
+ 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 Diffbroker
10
+ # One outbound WebSocket to Diff Broker'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