superkick 0.1.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/CLA.md +91 -0
- data/CLAUDE.md +2226 -0
- data/CONTRIBUTING.md +104 -0
- data/LICENSE +108 -0
- data/LICENSE-COMMERCIAL.md +39 -0
- data/PLAN.md +161 -0
- data/README.md +1155 -0
- data/exe/superkick +6 -0
- data/lib/superkick/agent/runtime.rb +82 -0
- data/lib/superkick/agent/runtimes/local.rb +74 -0
- data/lib/superkick/agent/runtimes.rb +4 -0
- data/lib/superkick/agent.rb +209 -0
- data/lib/superkick/agent_store.rb +85 -0
- data/lib/superkick/attach/client.rb +245 -0
- data/lib/superkick/attach/protocol.rb +71 -0
- data/lib/superkick/attach/server.rb +371 -0
- data/lib/superkick/budget_checker.rb +120 -0
- data/lib/superkick/buffer/client.rb +91 -0
- data/lib/superkick/buffer/server.rb +127 -0
- data/lib/superkick/cli/agent.rb +524 -0
- data/lib/superkick/cli/completion.rb +591 -0
- data/lib/superkick/cli/goal.rb +71 -0
- data/lib/superkick/cli/mcp.rb +34 -0
- data/lib/superkick/cli/monitor.rb +47 -0
- data/lib/superkick/cli/notifier.rb +39 -0
- data/lib/superkick/cli/repository.rb +46 -0
- data/lib/superkick/cli/server.rb +106 -0
- data/lib/superkick/cli/setup.rb +166 -0
- data/lib/superkick/cli/spawner.rb +85 -0
- data/lib/superkick/cli/team.rb +407 -0
- data/lib/superkick/cli.rb +175 -0
- data/lib/superkick/client_registry.rb +30 -0
- data/lib/superkick/configuration.rb +178 -0
- data/lib/superkick/connection.rb +56 -0
- data/lib/superkick/control/client.rb +78 -0
- data/lib/superkick/control/reply.rb +43 -0
- data/lib/superkick/control/server.rb +1271 -0
- data/lib/superkick/cost_accumulator.rb +53 -0
- data/lib/superkick/cost_extractor.rb +65 -0
- data/lib/superkick/cost_poller.rb +70 -0
- data/lib/superkick/driver/profile_source.rb +134 -0
- data/lib/superkick/driver.rb +179 -0
- data/lib/superkick/drivers/claude_code.rb +110 -0
- data/lib/superkick/drivers/codex.rb +57 -0
- data/lib/superkick/drivers/copilot.rb +75 -0
- data/lib/superkick/drivers/gemini.rb +86 -0
- data/lib/superkick/drivers/goose.rb +74 -0
- data/lib/superkick/drivers.rb +16 -0
- data/lib/superkick/drop.rb +80 -0
- data/lib/superkick/drops.rb +76 -0
- data/lib/superkick/environment_executor.rb +90 -0
- data/lib/superkick/goal.rb +95 -0
- data/lib/superkick/goals/agent_exit.rb +41 -0
- data/lib/superkick/goals/agent_signal.rb +42 -0
- data/lib/superkick/goals/command.rb +103 -0
- data/lib/superkick/history_buffer.rb +38 -0
- data/lib/superkick/hosted/attach/bridge.rb +52 -0
- data/lib/superkick/hosted/attach/client.rb +208 -0
- data/lib/superkick/hosted/attach/relay.rb +313 -0
- data/lib/superkick/hosted/attach/relay_store.rb +48 -0
- data/lib/superkick/hosted/bridge.rb +263 -0
- data/lib/superkick/hosted/buffer/bridge.rb +42 -0
- data/lib/superkick/hosted/buffer/client.rb +63 -0
- data/lib/superkick/hosted/buffer/relay.rb +126 -0
- data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
- data/lib/superkick/hosted/control/client.rb +84 -0
- data/lib/superkick/hosted/mcp_proxy.rb +144 -0
- data/lib/superkick/inject_handler.rb +24 -0
- data/lib/superkick/injection_guard.rb +26 -0
- data/lib/superkick/injection_queue.rb +177 -0
- data/lib/superkick/injector.rb +65 -0
- data/lib/superkick/input_buffer.rb +171 -0
- data/lib/superkick/integrations/bugsnag/README.md +98 -0
- data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
- data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/bugsnag.rb +7 -0
- data/lib/superkick/integrations/circleci/README.md +75 -0
- data/lib/superkick/integrations/circleci/monitor.rb +185 -0
- data/lib/superkick/integrations/circleci/probe.rb +36 -0
- data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
- data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/circleci.rb +8 -0
- data/lib/superkick/integrations/datadog/README.md +253 -0
- data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
- data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
- data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
- data/lib/superkick/integrations/datadog/notifier.rb +294 -0
- data/lib/superkick/integrations/datadog/spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
- data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
- data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
- data/lib/superkick/integrations/datadog.rb +14 -0
- data/lib/superkick/integrations/docker/README.md +256 -0
- data/lib/superkick/integrations/docker/client.rb +295 -0
- data/lib/superkick/integrations/docker/runtime.rb +218 -0
- data/lib/superkick/integrations/docker.rb +4 -0
- data/lib/superkick/integrations/git/repository_source.rb +66 -0
- data/lib/superkick/integrations/git/version_control.rb +119 -0
- data/lib/superkick/integrations/git.rb +8 -0
- data/lib/superkick/integrations/github/README.md +300 -0
- data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
- data/lib/superkick/integrations/github/drops.rb +114 -0
- data/lib/superkick/integrations/github/goal.rb +135 -0
- data/lib/superkick/integrations/github/issue_goal.rb +104 -0
- data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
- data/lib/superkick/integrations/github/monitor.rb +251 -0
- data/lib/superkick/integrations/github/probe.rb +30 -0
- data/lib/superkick/integrations/github/repository_source.rb +228 -0
- data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
- data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
- data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
- data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
- data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
- data/lib/superkick/integrations/github.rb +16 -0
- data/lib/superkick/integrations/honeybadger/README.md +97 -0
- data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
- data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
- data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
- data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/honeybadger.rb +9 -0
- data/lib/superkick/integrations/shell/README.md +83 -0
- data/lib/superkick/integrations/shell/monitor.rb +87 -0
- data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
- data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
- data/lib/superkick/integrations/shell.rb +7 -0
- data/lib/superkick/integrations/shortcut/README.md +193 -0
- data/lib/superkick/integrations/shortcut/drops.rb +91 -0
- data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
- data/lib/superkick/integrations/shortcut/probe.rb +34 -0
- data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
- data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
- data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
- data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
- data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
- data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
- data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
- data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
- data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
- data/lib/superkick/integrations/shortcut.rb +11 -0
- data/lib/superkick/integrations/slack/README.md +297 -0
- data/lib/superkick/integrations/slack/drops.rb +70 -0
- data/lib/superkick/integrations/slack/notifier.rb +426 -0
- data/lib/superkick/integrations/slack/spawner.rb +251 -0
- data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
- data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
- data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
- data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
- data/lib/superkick/integrations/slack.rb +12 -0
- data/lib/superkick/liquid.rb +129 -0
- data/lib/superkick/local/repository_source.rb +148 -0
- data/lib/superkick/mcp_server.rb +596 -0
- data/lib/superkick/monitor.rb +215 -0
- data/lib/superkick/notification_dispatcher.rb +280 -0
- data/lib/superkick/notifier.rb +173 -0
- data/lib/superkick/notifier_state_store.rb +55 -0
- data/lib/superkick/notifier_template.rb +121 -0
- data/lib/superkick/notifiers/command.rb +124 -0
- data/lib/superkick/notifiers/terminal_bell.rb +41 -0
- data/lib/superkick/output_logger.rb +54 -0
- data/lib/superkick/poller.rb +126 -0
- data/lib/superkick/process_runner.rb +87 -0
- data/lib/superkick/pty_proxy.rb +403 -0
- data/lib/superkick/registry.rb +75 -0
- data/lib/superkick/repository_source.rb +195 -0
- data/lib/superkick/server.rb +211 -0
- data/lib/superkick/session_recorder.rb +154 -0
- data/lib/superkick/setup.rb +160 -0
- data/lib/superkick/spawn/agent_spawner.rb +311 -0
- data/lib/superkick/spawn/approval_store.rb +113 -0
- data/lib/superkick/spawn/handler.rb +144 -0
- data/lib/superkick/spawn/injector.rb +119 -0
- data/lib/superkick/spawn/workflow_executor.rb +196 -0
- data/lib/superkick/spawn/workflow_validator.rb +77 -0
- data/lib/superkick/spawner.rb +67 -0
- data/lib/superkick/supervisor.rb +516 -0
- data/lib/superkick/team/artifact_store.rb +92 -0
- data/lib/superkick/team/log.rb +140 -0
- data/lib/superkick/team/log_entry_drop.rb +34 -0
- data/lib/superkick/team/log_monitor.rb +84 -0
- data/lib/superkick/team/log_notifier.rb +96 -0
- data/lib/superkick/team/log_store.rb +40 -0
- data/lib/superkick/template_filters.rb +24 -0
- data/lib/superkick/template_renderer.rb +223 -0
- data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
- data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
- data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
- data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
- data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
- data/lib/superkick/version.rb +5 -0
- data/lib/superkick/version_control.rb +135 -0
- data/lib/superkick/yaml_config.rb +302 -0
- data/lib/superkick.rb +198 -0
- data/plan.md +267 -0
- metadata +404 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
require "socket"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Superkick
|
|
9
|
+
module Integrations
|
|
10
|
+
module Docker
|
|
11
|
+
# Thin Faraday-based wrapper around the Docker Engine API.
|
|
12
|
+
#
|
|
13
|
+
# Supports Unix socket and TCP+TLS connections. Accepts `connection:`
|
|
14
|
+
# for test injection (Faraday test adapter).
|
|
15
|
+
#
|
|
16
|
+
# Only covers the endpoints needed by the Docker runtime:
|
|
17
|
+
# containers (create/start/stop/remove/inspect) and images (pull/exists).
|
|
18
|
+
class Client
|
|
19
|
+
API_VERSION = "v1.47"
|
|
20
|
+
TIMEOUT = 30 # seconds
|
|
21
|
+
|
|
22
|
+
class Error < StandardError
|
|
23
|
+
attr_reader :status, :body
|
|
24
|
+
|
|
25
|
+
def initialize(message, status: nil, body: nil)
|
|
26
|
+
@status = status
|
|
27
|
+
@body = body
|
|
28
|
+
super(message)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class NotFoundError < Error; end
|
|
33
|
+
class ConflictError < Error; end
|
|
34
|
+
class ConnectionError < Error; end
|
|
35
|
+
|
|
36
|
+
# @param host [String] Docker daemon address (unix:// or tcp://)
|
|
37
|
+
# @param tls [Hash, nil] TLS config { ca:, cert:, key: } for TCP
|
|
38
|
+
# @param connection [Faraday::Connection, nil] pre-built connection (for testing)
|
|
39
|
+
def initialize(host: "unix:///var/run/docker.sock", tls: nil, connection: nil)
|
|
40
|
+
@host = host
|
|
41
|
+
@tls = tls || {}
|
|
42
|
+
@connection = connection || build_connection
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# POST /containers/create?name=<name>
|
|
46
|
+
#
|
|
47
|
+
# @param name [String] container name
|
|
48
|
+
# @param config [Hash] Docker container config (Image, Cmd, Env, etc.)
|
|
49
|
+
# @return [Hash] { id: String, warnings: Array }
|
|
50
|
+
def create_container(name:, config:)
|
|
51
|
+
response = request(:post, "/containers/create", params: {name:}, body: config)
|
|
52
|
+
{id: response[:Id], warnings: response[:Warnings] || []}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# POST /containers/<id>/start
|
|
56
|
+
def start_container(container_id:)
|
|
57
|
+
request(:post, "/containers/#{container_id}/start")
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# POST /containers/<id>/stop?t=<timeout>
|
|
62
|
+
def stop_container(container_id:, timeout: 30)
|
|
63
|
+
request(:post, "/containers/#{container_id}/stop", params: {t: timeout})
|
|
64
|
+
nil
|
|
65
|
+
rescue NotFoundError
|
|
66
|
+
nil # already gone
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# DELETE /containers/<id>?force=<force>
|
|
70
|
+
def remove_container(container_id:, force: false)
|
|
71
|
+
request(:delete, "/containers/#{container_id}", params: {force:})
|
|
72
|
+
nil
|
|
73
|
+
rescue NotFoundError
|
|
74
|
+
nil # already gone
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# GET /containers/<id>/json
|
|
78
|
+
#
|
|
79
|
+
# @return [Hash] full container inspect response (string keys from Docker API)
|
|
80
|
+
def inspect_container(container_id:)
|
|
81
|
+
request(:get, "/containers/#{container_id}/json", symbolize: false)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# POST /images/create?fromImage=<image>&tag=<tag>
|
|
85
|
+
#
|
|
86
|
+
# Blocks until the pull completes. Docker streams progress as JSON lines;
|
|
87
|
+
# we consume them all and discard.
|
|
88
|
+
def pull_image(image:, registry_auth: nil)
|
|
89
|
+
image_ref, tag = parse_image_ref(image)
|
|
90
|
+
headers = {}
|
|
91
|
+
headers["X-Registry-Auth"] = registry_auth if registry_auth
|
|
92
|
+
|
|
93
|
+
# The pull endpoint streams JSON progress — we just consume the full response.
|
|
94
|
+
request(:post, "/images/create",
|
|
95
|
+
params: {fromImage: image_ref, tag:},
|
|
96
|
+
headers:,
|
|
97
|
+
symbolize: false)
|
|
98
|
+
nil
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# GET /images/<image>/json
|
|
102
|
+
#
|
|
103
|
+
# @return [Boolean] true if image exists locally
|
|
104
|
+
def image_exists?(image:)
|
|
105
|
+
request(:get, "/images/#{ERB::Util.url_encode(image)}/json", symbolize: false)
|
|
106
|
+
true
|
|
107
|
+
rescue NotFoundError
|
|
108
|
+
false
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# GET /_ping
|
|
112
|
+
#
|
|
113
|
+
# @return [Boolean] true if Docker daemon is reachable
|
|
114
|
+
def ping
|
|
115
|
+
@connection.get("/#{API_VERSION}/_ping")
|
|
116
|
+
true
|
|
117
|
+
rescue Faraday::Error
|
|
118
|
+
false
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def request(method, path, params: nil, body: nil, headers: nil, symbolize: true)
|
|
124
|
+
url = "/#{API_VERSION}#{path}"
|
|
125
|
+
|
|
126
|
+
response = @connection.run_request(method, url, body&.to_json, headers) do |req|
|
|
127
|
+
req.params.update(params) if params
|
|
128
|
+
req.headers["Content-Type"] = "application/json" if body
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
handle_response(response, symbolize:)
|
|
132
|
+
rescue Faraday::ConnectionFailed => e
|
|
133
|
+
raise ConnectionError.new("Docker connection failed: #{e.message}")
|
|
134
|
+
rescue Faraday::TimeoutError => e
|
|
135
|
+
raise ConnectionError.new("Docker request timed out: #{e.message}")
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def handle_response(response, symbolize: true)
|
|
139
|
+
case response.status
|
|
140
|
+
when 200, 201, 204
|
|
141
|
+
return nil if response.body.nil? || response.body.empty?
|
|
142
|
+
JSON.parse(response.body, symbolize_names: symbolize)
|
|
143
|
+
when 304
|
|
144
|
+
nil
|
|
145
|
+
when 404
|
|
146
|
+
raise NotFoundError.new(error_message(response), status: 404, body: response.body)
|
|
147
|
+
when 409
|
|
148
|
+
raise ConflictError.new(error_message(response), status: 409, body: response.body)
|
|
149
|
+
else
|
|
150
|
+
raise Error.new(error_message(response), status: response.status, body: response.body)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def error_message(response)
|
|
155
|
+
parsed = JSON.parse(response.body, symbolize_names: true)
|
|
156
|
+
parsed[:message] || "Docker API error (HTTP #{response.status})"
|
|
157
|
+
rescue JSON::ParserError
|
|
158
|
+
"Docker API error (HTTP #{response.status})"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def parse_image_ref(image)
|
|
162
|
+
# Split "image:tag" into ["image", "tag"], defaulting tag to "latest"
|
|
163
|
+
parts = image.split(":", 2)
|
|
164
|
+
if parts.length == 2 && !parts[1].include?("/")
|
|
165
|
+
[parts[0], parts[1]]
|
|
166
|
+
else
|
|
167
|
+
[image, "latest"]
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def build_connection
|
|
172
|
+
if @host.start_with?("unix://")
|
|
173
|
+
build_unix_connection
|
|
174
|
+
else
|
|
175
|
+
build_tcp_connection
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def build_unix_connection
|
|
180
|
+
socket_path = @host.delete_prefix("unix://")
|
|
181
|
+
|
|
182
|
+
Faraday.new(url: "http://localhost") do |f|
|
|
183
|
+
f.options.timeout = TIMEOUT
|
|
184
|
+
f.options.open_timeout = TIMEOUT
|
|
185
|
+
f.adapter :test # placeholder — replaced below
|
|
186
|
+
end.tap do |conn|
|
|
187
|
+
# Replace the adapter with our Unix socket adapter
|
|
188
|
+
conn.builder.delete(Faraday::Adapter::Test)
|
|
189
|
+
conn.builder.adapter(UnixSocketAdapter, socket_path:)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def build_tcp_connection
|
|
194
|
+
url = @host.sub(/\Atcp:\/\//, "https://")
|
|
195
|
+
ssl_options = {}
|
|
196
|
+
|
|
197
|
+
if @tls[:ca]
|
|
198
|
+
ssl_options[:ca_file] = @tls[:ca]
|
|
199
|
+
ssl_options[:client_cert] = OpenSSL::X509::Certificate.new(File.read(@tls[:cert])) if @tls[:cert]
|
|
200
|
+
ssl_options[:client_key] = OpenSSL::PKey.read(File.read(@tls[:key])) if @tls[:key]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
Faraday.new(url:, ssl: ssl_options) do |f|
|
|
204
|
+
f.options.timeout = TIMEOUT
|
|
205
|
+
f.options.open_timeout = TIMEOUT
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Minimal Faraday adapter that sends HTTP requests over a Unix socket.
|
|
210
|
+
# Only handles the simple synchronous request/response pattern used by
|
|
211
|
+
# the Docker Engine API.
|
|
212
|
+
class UnixSocketAdapter < Faraday::Adapter
|
|
213
|
+
def initialize(app, socket_path:)
|
|
214
|
+
super(app)
|
|
215
|
+
@socket_path = socket_path
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def call(env)
|
|
219
|
+
super
|
|
220
|
+
|
|
221
|
+
sock = UNIXSocket.new(@socket_path)
|
|
222
|
+
begin
|
|
223
|
+
write_request(sock, env)
|
|
224
|
+
status, headers, body = read_response(sock)
|
|
225
|
+
|
|
226
|
+
save_response(env, status, body, headers)
|
|
227
|
+
ensure
|
|
228
|
+
sock.close
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
private
|
|
233
|
+
|
|
234
|
+
def write_request(sock, env)
|
|
235
|
+
path = env.url.request_uri
|
|
236
|
+
method = env.method.to_s.upcase
|
|
237
|
+
|
|
238
|
+
sock.write("#{method} #{path} HTTP/1.1\r\n")
|
|
239
|
+
sock.write("Host: localhost\r\n")
|
|
240
|
+
|
|
241
|
+
env.request_headers.each do |key, value|
|
|
242
|
+
sock.write("#{key}: #{value}\r\n")
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
if env.body
|
|
246
|
+
sock.write("Content-Length: #{env.body.bytesize}\r\n")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
sock.write("\r\n")
|
|
250
|
+
sock.write(env.body) if env.body
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def read_response(sock)
|
|
254
|
+
status_line = sock.gets
|
|
255
|
+
status = status_line.split(" ", 3)[1].to_i
|
|
256
|
+
|
|
257
|
+
headers = {}
|
|
258
|
+
while (line = sock.gets) && line != "\r\n"
|
|
259
|
+
key, value = line.split(":", 2)
|
|
260
|
+
headers[key.strip.downcase] = value.strip
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
body = read_body(sock, headers)
|
|
264
|
+
[status, headers, body]
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def read_body(sock, headers)
|
|
268
|
+
if headers["transfer-encoding"]&.include?("chunked")
|
|
269
|
+
read_chunked_body(sock)
|
|
270
|
+
elsif headers["content-length"]
|
|
271
|
+
sock.read(headers["content-length"].to_i)
|
|
272
|
+
else
|
|
273
|
+
sock.read
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def read_chunked_body(sock)
|
|
278
|
+
body = +""
|
|
279
|
+
loop do
|
|
280
|
+
size_line = sock.gets
|
|
281
|
+
break unless size_line
|
|
282
|
+
|
|
283
|
+
size = size_line.strip.to_i(16)
|
|
284
|
+
break if size == 0
|
|
285
|
+
|
|
286
|
+
body << sock.read(size)
|
|
287
|
+
sock.gets # consume trailing \r\n
|
|
288
|
+
end
|
|
289
|
+
body
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
end
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Superkick
|
|
7
|
+
module Integrations
|
|
8
|
+
module Docker
|
|
9
|
+
# Docker runtime — provisions spawned agents in Docker containers via the
|
|
10
|
+
# Docker Engine API. Supports both local mode (Unix socket mount) and hosted
|
|
11
|
+
# mode (HTTPS + WebSocket) for server connectivity.
|
|
12
|
+
#
|
|
13
|
+
# When the +server+ context indicates local transport, the runtime
|
|
14
|
+
# auto-injects a bind mount for the Superkick run directory and sets
|
|
15
|
+
# SUPERKICK_DIR in the container so agents can communicate with the
|
|
16
|
+
# local server without manual volume configuration.
|
|
17
|
+
class Runtime < Superkick::Agent::Runtime
|
|
18
|
+
Handle = Data.define(:container_id, :container_name)
|
|
19
|
+
|
|
20
|
+
MEMORY_UNITS = {"K" => 1024, "M" => 1024**2, "G" => 1024**3, "T" => 1024**4}.freeze
|
|
21
|
+
NANO_CPUS_PER_CPU = 1_000_000_000
|
|
22
|
+
CONTAINER_SUPERKICK_DIR = "/superkick"
|
|
23
|
+
|
|
24
|
+
def self.type = :docker
|
|
25
|
+
|
|
26
|
+
# @param image [String] Docker image (required)
|
|
27
|
+
# @param host [String] Docker daemon address (default: Unix socket)
|
|
28
|
+
# @param tls [Hash, nil] TLS config { ca:, cert:, key: } for TCP
|
|
29
|
+
# @param pull_policy [Symbol] :always, :missing, :never (default: :missing)
|
|
30
|
+
# @param registry [Hash, nil] { username:, password:, server: } for private registries
|
|
31
|
+
# @param memory [String, Integer, nil] memory limit (e.g. "4G", 4294967296)
|
|
32
|
+
# @param cpu [Numeric, nil] CPU cores (e.g. 2 → 2_000_000_000 NanoCPUs)
|
|
33
|
+
# @param network [String, nil] Docker network name
|
|
34
|
+
# @param stop_timeout [Integer] seconds before SIGKILL after SIGTERM (default: 30)
|
|
35
|
+
# @param auto_remove [Boolean] remove container after exit (default: true)
|
|
36
|
+
# @param env [Hash, nil] additional environment variables
|
|
37
|
+
# @param volumes [Array, nil] volume mount strings (e.g. ["/host:/container:ro"])
|
|
38
|
+
# @param labels [Hash, nil] container labels
|
|
39
|
+
# @param privileged [Boolean] run container in privileged mode (default: false)
|
|
40
|
+
# @param cap_add [Array, nil] Linux capabilities to add (e.g. ["SYS_ADMIN"])
|
|
41
|
+
# @param security_opt [Array, nil] security options (e.g. ["seccomp:unconfined"])
|
|
42
|
+
# @param container_runtime [String, nil] OCI runtime (e.g. "sysbox-runc")
|
|
43
|
+
# @param server [Hash] Server context from Configuration ({ type:, base_dir: })
|
|
44
|
+
# @param connection [Faraday::Connection, nil] pre-built connection for testing
|
|
45
|
+
def initialize(image:, host: "unix:///var/run/docker.sock", tls: nil,
|
|
46
|
+
pull_policy: :missing, registry: nil, memory: nil, cpu: nil,
|
|
47
|
+
network: nil, stop_timeout: 30, auto_remove: true, env: nil,
|
|
48
|
+
volumes: nil, labels: nil, privileged: false, cap_add: nil,
|
|
49
|
+
security_opt: nil, container_runtime: nil, server: {},
|
|
50
|
+
connection: nil)
|
|
51
|
+
@image = image
|
|
52
|
+
@pull_policy = pull_policy.to_sym
|
|
53
|
+
@registry = registry
|
|
54
|
+
@memory = memory
|
|
55
|
+
@cpu = cpu
|
|
56
|
+
@network = network
|
|
57
|
+
@stop_timeout = stop_timeout
|
|
58
|
+
@auto_remove = auto_remove
|
|
59
|
+
@env = env || {}
|
|
60
|
+
@volumes = volumes || []
|
|
61
|
+
@labels = labels || {}
|
|
62
|
+
@privileged = privileged
|
|
63
|
+
@cap_add = cap_add || []
|
|
64
|
+
@security_opt = security_opt || []
|
|
65
|
+
@container_runtime = container_runtime
|
|
66
|
+
@client = Client.new(host:, tls:, connection:)
|
|
67
|
+
|
|
68
|
+
apply_local_connectivity(server[:base_dir]) if server[:type] == :local
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Provision a Docker container for the agent.
|
|
72
|
+
#
|
|
73
|
+
# @param agent_id [String]
|
|
74
|
+
# @param config [Hash] { env: Hash, command: Array, working_dir: String }
|
|
75
|
+
# @return [Handle]
|
|
76
|
+
def provision(agent_id:, config:)
|
|
77
|
+
ensure_image_available
|
|
78
|
+
|
|
79
|
+
container_name = "superkick-#{agent_id}"
|
|
80
|
+
container_config = build_container_config(agent_id:, config:)
|
|
81
|
+
|
|
82
|
+
result = @client.create_container(name: container_name, config: container_config)
|
|
83
|
+
container_id = result[:id]
|
|
84
|
+
|
|
85
|
+
begin
|
|
86
|
+
@client.start_container(container_id:)
|
|
87
|
+
rescue Client::Error => e
|
|
88
|
+
# Clean up the created container if start fails
|
|
89
|
+
@client.remove_container(container_id:, force: true)
|
|
90
|
+
raise e
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Superkick.logger.info("runtime:docker") do
|
|
94
|
+
"Started container #{container_id[0..11]} (#{container_name}) for #{agent_id}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
Handle.new(container_id:, container_name:)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Stop and optionally remove the container.
|
|
101
|
+
#
|
|
102
|
+
# @param handle [Handle]
|
|
103
|
+
def terminate(handle:)
|
|
104
|
+
@client.stop_container(container_id: handle.container_id, timeout: @stop_timeout)
|
|
105
|
+
@client.remove_container(container_id: handle.container_id, force: true) unless @auto_remove
|
|
106
|
+
rescue Client::NotFoundError
|
|
107
|
+
nil # container already gone
|
|
108
|
+
rescue Client::Error => e
|
|
109
|
+
Superkick.logger.warn("runtime:docker") { "Terminate error for #{handle.container_id}: #{e.message}" }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Check if the container is still running.
|
|
113
|
+
#
|
|
114
|
+
# @param handle [Handle]
|
|
115
|
+
# @return [Boolean]
|
|
116
|
+
def alive?(handle:)
|
|
117
|
+
info = @client.inspect_container(container_id: handle.container_id)
|
|
118
|
+
info.dig("State", "Running") == true
|
|
119
|
+
rescue Client::Error
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param handle [Handle]
|
|
124
|
+
# @return [Hash]
|
|
125
|
+
def metadata(handle:)
|
|
126
|
+
{container_id: handle.container_id, container_name: handle.container_name}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def ensure_image_available
|
|
132
|
+
case @pull_policy
|
|
133
|
+
when :always
|
|
134
|
+
pull_image
|
|
135
|
+
when :missing
|
|
136
|
+
pull_image unless @client.image_exists?(image: @image)
|
|
137
|
+
when :never
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def pull_image
|
|
143
|
+
auth = build_registry_auth
|
|
144
|
+
Superkick.logger.info("runtime:docker") { "Pulling image #{@image}" }
|
|
145
|
+
@client.pull_image(image: @image, registry_auth: auth)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def build_registry_auth
|
|
149
|
+
return nil unless @registry&.any?
|
|
150
|
+
|
|
151
|
+
Base64.urlsafe_encode64({
|
|
152
|
+
username: @registry[:username],
|
|
153
|
+
password: @registry[:password],
|
|
154
|
+
serveraddress: @registry[:server]
|
|
155
|
+
}.compact.to_json)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def build_container_config(agent_id:, config:)
|
|
159
|
+
merged_env = @env.merge(config[:env] || {})
|
|
160
|
+
env_array = merged_env.map { |k, v| "#{k}=#{v}" }
|
|
161
|
+
|
|
162
|
+
{
|
|
163
|
+
Image: @image,
|
|
164
|
+
Cmd: config[:command],
|
|
165
|
+
Env: env_array,
|
|
166
|
+
WorkingDir: config[:working_dir],
|
|
167
|
+
Labels: @labels.merge("superkick.agent_id" => agent_id),
|
|
168
|
+
StopTimeout: @stop_timeout,
|
|
169
|
+
HostConfig: build_host_config
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def build_host_config
|
|
174
|
+
host_config = {}
|
|
175
|
+
host_config[:Memory] = parse_memory(@memory) if @memory
|
|
176
|
+
host_config[:NanoCpus] = (@cpu * NANO_CPUS_PER_CPU).to_i if @cpu
|
|
177
|
+
host_config[:NetworkMode] = @network if @network
|
|
178
|
+
host_config[:Binds] = @volumes if @volumes.any?
|
|
179
|
+
host_config[:AutoRemove] = @auto_remove
|
|
180
|
+
host_config[:Privileged] = true if @privileged
|
|
181
|
+
host_config[:CapAdd] = @cap_add if @cap_add.any?
|
|
182
|
+
host_config[:SecurityOpt] = @security_opt if @security_opt.any?
|
|
183
|
+
host_config[:Runtime] = @container_runtime if @container_runtime
|
|
184
|
+
host_config
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Auto-inject run directory mount and SUPERKICK_DIR env var for local
|
|
188
|
+
# server connectivity. The env var is set as a default — user-configured
|
|
189
|
+
# env values take precedence.
|
|
190
|
+
def apply_local_connectivity(base_dir)
|
|
191
|
+
run_dir = File.join(base_dir, "run")
|
|
192
|
+
container_run_dir = File.join(CONTAINER_SUPERKICK_DIR, "run")
|
|
193
|
+
|
|
194
|
+
@volumes += ["#{run_dir}:#{container_run_dir}"]
|
|
195
|
+
@env = {"SUPERKICK_DIR" => CONTAINER_SUPERKICK_DIR}.merge(@env)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def parse_memory(value)
|
|
199
|
+
return value if value.is_a?(Integer)
|
|
200
|
+
|
|
201
|
+
match = value.to_s.match(/\A(\d+(?:\.\d+)?)\s*([KMGT])?B?\z/i)
|
|
202
|
+
raise ArgumentError, "Invalid memory value: #{value.inspect}" unless match
|
|
203
|
+
|
|
204
|
+
number = match[1].to_f
|
|
205
|
+
unit = match[2]&.upcase
|
|
206
|
+
|
|
207
|
+
if unit && MEMORY_UNITS[unit]
|
|
208
|
+
(number * MEMORY_UNITS[unit]).to_i
|
|
209
|
+
else
|
|
210
|
+
number.to_i
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
Superkick::Agent::Runtime.register(Superkick::Integrations::Docker::Runtime)
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Integrations
|
|
5
|
+
module Git
|
|
6
|
+
# Git::RepositorySource — single-repository source for URL-based git repos.
|
|
7
|
+
#
|
|
8
|
+
# Use when the repository is remote (no local path). The URL is stored on
|
|
9
|
+
# the Repository and used by the Git VersionControl adapter at acquire time.
|
|
10
|
+
#
|
|
11
|
+
# Config:
|
|
12
|
+
# type: git
|
|
13
|
+
# url: git@github.com:company/api.git # required
|
|
14
|
+
#
|
|
15
|
+
# YAML usage:
|
|
16
|
+
# repositories:
|
|
17
|
+
# api:
|
|
18
|
+
# type: git
|
|
19
|
+
# url: git@github.com:company/api.git
|
|
20
|
+
class RepositorySource < Superkick::RepositorySource
|
|
21
|
+
def self.type = :git
|
|
22
|
+
|
|
23
|
+
def self.setup_label = "Git URL"
|
|
24
|
+
|
|
25
|
+
def self.setup_config
|
|
26
|
+
<<~YAML
|
|
27
|
+
upstream:
|
|
28
|
+
type: git
|
|
29
|
+
url: https://github.com/org/repo.git
|
|
30
|
+
YAML
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(config = {})
|
|
34
|
+
@url = config[:url]
|
|
35
|
+
raise ArgumentError, "Git repository source requires url:" unless @url
|
|
36
|
+
|
|
37
|
+
name = config[:name]&.to_sym || infer_name(@url)
|
|
38
|
+
|
|
39
|
+
@repositories = {
|
|
40
|
+
name => Repository.new(
|
|
41
|
+
name:,
|
|
42
|
+
url: @url,
|
|
43
|
+
version_control: :git
|
|
44
|
+
)
|
|
45
|
+
}.freeze
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attr_reader :repositories
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def infer_name(url)
|
|
53
|
+
# Extract repo name from URL:
|
|
54
|
+
# git@github.com:org/repo.git → repo
|
|
55
|
+
# https://github.com/org/repo.git → repo
|
|
56
|
+
# https://github.com/org/repo → repo
|
|
57
|
+
basename = url.split("/").last || url.split(":").last
|
|
58
|
+
basename = basename.sub(/\.git\z/, "")
|
|
59
|
+
basename.to_sym
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
RepositorySource.register(Integrations::Git::RepositorySource)
|
|
66
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Integrations
|
|
5
|
+
module Git
|
|
6
|
+
# Git version control adapter.
|
|
7
|
+
#
|
|
8
|
+
# Acquires an isolated working copy using the best available strategy:
|
|
9
|
+
# - When the source has a local path → use git worktree (fast, shared objects)
|
|
10
|
+
# - When only a URL is available → git clone + create branch
|
|
11
|
+
#
|
|
12
|
+
# Teardown reverses whichever strategy was used.
|
|
13
|
+
class VersionControl < Superkick::VersionControl
|
|
14
|
+
def self.type = :git
|
|
15
|
+
|
|
16
|
+
# Probe — detects git repositories by the presence of .git.
|
|
17
|
+
# .git can be a directory (normal repo) or a file (worktree).
|
|
18
|
+
class Probe < Superkick::VersionControl::Probe
|
|
19
|
+
def self.type = :git
|
|
20
|
+
|
|
21
|
+
def self.detect_at(path:)
|
|
22
|
+
return {type: :git} if File.exist?(File.join(path, ".git"))
|
|
23
|
+
nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Acquire an isolated working copy.
|
|
28
|
+
#
|
|
29
|
+
# @param source [Repository] the repository (must have path or url)
|
|
30
|
+
# @param destination [String] absolute path for the working copy
|
|
31
|
+
# @param branch [String] branch name to create
|
|
32
|
+
# @param base_branch [String] branch to base from (default: "main")
|
|
33
|
+
def acquire(source:, destination:, branch:, base_branch: "main")
|
|
34
|
+
if source.path && File.directory?(source.path)
|
|
35
|
+
acquire_via_worktree(source_path: source.path, destination:, branch:, base_branch:)
|
|
36
|
+
elsif source.url
|
|
37
|
+
acquire_via_clone(url: source.url, destination:, branch:, base_branch:)
|
|
38
|
+
else
|
|
39
|
+
raise Poller::FatalError,
|
|
40
|
+
"Repository #{source.name} has neither a local path nor a URL — cannot acquire"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Remove the isolated working copy.
|
|
45
|
+
#
|
|
46
|
+
# @param destination [String] absolute path of the working copy
|
|
47
|
+
def teardown(destination:)
|
|
48
|
+
return unless destination && File.directory?(destination)
|
|
49
|
+
|
|
50
|
+
git_file = File.join(destination, ".git")
|
|
51
|
+
|
|
52
|
+
if File.file?(git_file) && File.read(git_file).start_with?("gitdir:")
|
|
53
|
+
teardown_worktree(destination:, git_file:)
|
|
54
|
+
else
|
|
55
|
+
teardown_clone(destination:)
|
|
56
|
+
end
|
|
57
|
+
rescue => e
|
|
58
|
+
Superkick.logger.warn("version_control:git") { "Teardown failed: #{e.message}" }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# ── Worktree strategy ────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
def acquire_via_worktree(source_path:, destination:, branch:, base_branch:)
|
|
66
|
+
return if File.directory?(destination)
|
|
67
|
+
|
|
68
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
69
|
+
|
|
70
|
+
ProcessRunner.run(
|
|
71
|
+
"git worktree add -b #{shell_escape(branch)} #{shell_escape(destination)} #{shell_escape(base_branch)}",
|
|
72
|
+
timeout: 60, chdir: source_path
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def teardown_worktree(destination:, git_file:)
|
|
77
|
+
content = File.read(git_file)
|
|
78
|
+
gitdir = content.sub("gitdir:", "").strip
|
|
79
|
+
repository_root = File.expand_path(File.join(gitdir, "..", "..", ".."))
|
|
80
|
+
|
|
81
|
+
return unless File.directory?(File.join(repository_root, ".git"))
|
|
82
|
+
|
|
83
|
+
ProcessRunner.run(
|
|
84
|
+
"git worktree remove #{shell_escape(destination)} --force",
|
|
85
|
+
timeout: 30, chdir: repository_root
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ── Clone strategy ──────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
def acquire_via_clone(url:, destination:, branch:, base_branch:)
|
|
92
|
+
return if File.directory?(destination)
|
|
93
|
+
|
|
94
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
95
|
+
|
|
96
|
+
ProcessRunner.run(
|
|
97
|
+
"git clone --branch #{shell_escape(base_branch)} #{shell_escape(url)} #{shell_escape(destination)}",
|
|
98
|
+
timeout: 300
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
ProcessRunner.run(
|
|
102
|
+
"git checkout -b #{shell_escape(branch)}",
|
|
103
|
+
timeout: 30, chdir: destination
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def teardown_clone(destination:)
|
|
108
|
+
FileUtils.rm_rf(destination)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ── Helpers ─────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
def shell_escape(str)
|
|
114
|
+
Shellwords.escape(str.to_s)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|