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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/exe/diffbroker +10 -0
- data/lib/diffbroker/agent_projects.rb +35 -0
- data/lib/diffbroker/agents/base.rb +48 -0
- data/lib/diffbroker/agents/claude.rb +100 -0
- data/lib/diffbroker/agents/codex.rb +72 -0
- data/lib/diffbroker/agents.rb +18 -0
- data/lib/diffbroker/api.rb +59 -0
- data/lib/diffbroker/branch_publisher.rb +66 -0
- data/lib/diffbroker/cable_client.rb +213 -0
- data/lib/diffbroker/cli.rb +347 -0
- data/lib/diffbroker/config.rb +126 -0
- data/lib/diffbroker/daemon.rb +277 -0
- data/lib/diffbroker/explain_verifier.rb +55 -0
- data/lib/diffbroker/gh_check.rb +22 -0
- data/lib/diffbroker/git_context.rb +115 -0
- data/lib/diffbroker/hook.rb +50 -0
- data/lib/diffbroker/hooks_installer.rb +115 -0
- data/lib/diffbroker/launcher.rb +62 -0
- data/lib/diffbroker/lifecycle.rb +127 -0
- data/lib/diffbroker/local_relay.rb +100 -0
- data/lib/diffbroker/login.rb +68 -0
- data/lib/diffbroker/maintenance.rb +65 -0
- data/lib/diffbroker/neutralizer.rb +80 -0
- data/lib/diffbroker/process_scanner.rb +104 -0
- data/lib/diffbroker/reaper.rb +111 -0
- data/lib/diffbroker/repo_scanner.rb +88 -0
- data/lib/diffbroker/run_script.rb +62 -0
- data/lib/diffbroker/runner.rb +41 -0
- data/lib/diffbroker/service_installer.rb +179 -0
- data/lib/diffbroker/skill_installer.rb +30 -0
- data/lib/diffbroker/task_files.rb +90 -0
- data/lib/diffbroker/task_runner.rb +286 -0
- data/lib/diffbroker/url_handler_installer.rb +111 -0
- data/lib/diffbroker/version.rb +5 -0
- data/lib/diffbroker/worktree.rb +115 -0
- data/lib/diffbroker.rb +46 -0
- data/plugin/.claude-plugin/plugin.json +5 -0
- data/plugin/skills/diffbroker/SKILL.md +36 -0
- data/plugin/skills/diffbroker-explain/SKILL.md +141 -0
- 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,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
|