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.
Files changed (199) hide show
  1. checksums.yaml +7 -0
  2. data/CLA.md +91 -0
  3. data/CLAUDE.md +2226 -0
  4. data/CONTRIBUTING.md +104 -0
  5. data/LICENSE +108 -0
  6. data/LICENSE-COMMERCIAL.md +39 -0
  7. data/PLAN.md +161 -0
  8. data/README.md +1155 -0
  9. data/exe/superkick +6 -0
  10. data/lib/superkick/agent/runtime.rb +82 -0
  11. data/lib/superkick/agent/runtimes/local.rb +74 -0
  12. data/lib/superkick/agent/runtimes.rb +4 -0
  13. data/lib/superkick/agent.rb +209 -0
  14. data/lib/superkick/agent_store.rb +85 -0
  15. data/lib/superkick/attach/client.rb +245 -0
  16. data/lib/superkick/attach/protocol.rb +71 -0
  17. data/lib/superkick/attach/server.rb +371 -0
  18. data/lib/superkick/budget_checker.rb +120 -0
  19. data/lib/superkick/buffer/client.rb +91 -0
  20. data/lib/superkick/buffer/server.rb +127 -0
  21. data/lib/superkick/cli/agent.rb +524 -0
  22. data/lib/superkick/cli/completion.rb +591 -0
  23. data/lib/superkick/cli/goal.rb +71 -0
  24. data/lib/superkick/cli/mcp.rb +34 -0
  25. data/lib/superkick/cli/monitor.rb +47 -0
  26. data/lib/superkick/cli/notifier.rb +39 -0
  27. data/lib/superkick/cli/repository.rb +46 -0
  28. data/lib/superkick/cli/server.rb +106 -0
  29. data/lib/superkick/cli/setup.rb +166 -0
  30. data/lib/superkick/cli/spawner.rb +85 -0
  31. data/lib/superkick/cli/team.rb +407 -0
  32. data/lib/superkick/cli.rb +175 -0
  33. data/lib/superkick/client_registry.rb +30 -0
  34. data/lib/superkick/configuration.rb +178 -0
  35. data/lib/superkick/connection.rb +56 -0
  36. data/lib/superkick/control/client.rb +78 -0
  37. data/lib/superkick/control/reply.rb +43 -0
  38. data/lib/superkick/control/server.rb +1271 -0
  39. data/lib/superkick/cost_accumulator.rb +53 -0
  40. data/lib/superkick/cost_extractor.rb +65 -0
  41. data/lib/superkick/cost_poller.rb +70 -0
  42. data/lib/superkick/driver/profile_source.rb +134 -0
  43. data/lib/superkick/driver.rb +179 -0
  44. data/lib/superkick/drivers/claude_code.rb +110 -0
  45. data/lib/superkick/drivers/codex.rb +57 -0
  46. data/lib/superkick/drivers/copilot.rb +75 -0
  47. data/lib/superkick/drivers/gemini.rb +86 -0
  48. data/lib/superkick/drivers/goose.rb +74 -0
  49. data/lib/superkick/drivers.rb +16 -0
  50. data/lib/superkick/drop.rb +80 -0
  51. data/lib/superkick/drops.rb +76 -0
  52. data/lib/superkick/environment_executor.rb +90 -0
  53. data/lib/superkick/goal.rb +95 -0
  54. data/lib/superkick/goals/agent_exit.rb +41 -0
  55. data/lib/superkick/goals/agent_signal.rb +42 -0
  56. data/lib/superkick/goals/command.rb +103 -0
  57. data/lib/superkick/history_buffer.rb +38 -0
  58. data/lib/superkick/hosted/attach/bridge.rb +52 -0
  59. data/lib/superkick/hosted/attach/client.rb +208 -0
  60. data/lib/superkick/hosted/attach/relay.rb +313 -0
  61. data/lib/superkick/hosted/attach/relay_store.rb +48 -0
  62. data/lib/superkick/hosted/bridge.rb +263 -0
  63. data/lib/superkick/hosted/buffer/bridge.rb +42 -0
  64. data/lib/superkick/hosted/buffer/client.rb +63 -0
  65. data/lib/superkick/hosted/buffer/relay.rb +126 -0
  66. data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
  67. data/lib/superkick/hosted/control/client.rb +84 -0
  68. data/lib/superkick/hosted/mcp_proxy.rb +144 -0
  69. data/lib/superkick/inject_handler.rb +24 -0
  70. data/lib/superkick/injection_guard.rb +26 -0
  71. data/lib/superkick/injection_queue.rb +177 -0
  72. data/lib/superkick/injector.rb +65 -0
  73. data/lib/superkick/input_buffer.rb +171 -0
  74. data/lib/superkick/integrations/bugsnag/README.md +98 -0
  75. data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
  76. data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
  77. data/lib/superkick/integrations/bugsnag.rb +7 -0
  78. data/lib/superkick/integrations/circleci/README.md +75 -0
  79. data/lib/superkick/integrations/circleci/monitor.rb +185 -0
  80. data/lib/superkick/integrations/circleci/probe.rb +36 -0
  81. data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
  82. data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
  83. data/lib/superkick/integrations/circleci.rb +8 -0
  84. data/lib/superkick/integrations/datadog/README.md +253 -0
  85. data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
  86. data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
  87. data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
  88. data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
  89. data/lib/superkick/integrations/datadog/notifier.rb +294 -0
  90. data/lib/superkick/integrations/datadog/spawner.rb +201 -0
  91. data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
  92. data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
  93. data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
  94. data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
  95. data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
  96. data/lib/superkick/integrations/datadog.rb +14 -0
  97. data/lib/superkick/integrations/docker/README.md +256 -0
  98. data/lib/superkick/integrations/docker/client.rb +295 -0
  99. data/lib/superkick/integrations/docker/runtime.rb +218 -0
  100. data/lib/superkick/integrations/docker.rb +4 -0
  101. data/lib/superkick/integrations/git/repository_source.rb +66 -0
  102. data/lib/superkick/integrations/git/version_control.rb +119 -0
  103. data/lib/superkick/integrations/git.rb +8 -0
  104. data/lib/superkick/integrations/github/README.md +300 -0
  105. data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
  106. data/lib/superkick/integrations/github/drops.rb +114 -0
  107. data/lib/superkick/integrations/github/goal.rb +135 -0
  108. data/lib/superkick/integrations/github/issue_goal.rb +104 -0
  109. data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
  110. data/lib/superkick/integrations/github/monitor.rb +251 -0
  111. data/lib/superkick/integrations/github/probe.rb +30 -0
  112. data/lib/superkick/integrations/github/repository_source.rb +228 -0
  113. data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
  114. data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
  115. data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
  116. data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
  117. data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
  118. data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
  119. data/lib/superkick/integrations/github.rb +16 -0
  120. data/lib/superkick/integrations/honeybadger/README.md +97 -0
  121. data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
  122. data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
  123. data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
  124. data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
  125. data/lib/superkick/integrations/honeybadger.rb +9 -0
  126. data/lib/superkick/integrations/shell/README.md +83 -0
  127. data/lib/superkick/integrations/shell/monitor.rb +87 -0
  128. data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
  129. data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
  130. data/lib/superkick/integrations/shell.rb +7 -0
  131. data/lib/superkick/integrations/shortcut/README.md +193 -0
  132. data/lib/superkick/integrations/shortcut/drops.rb +91 -0
  133. data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
  134. data/lib/superkick/integrations/shortcut/probe.rb +34 -0
  135. data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
  136. data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
  137. data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
  138. data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
  139. data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
  140. data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
  141. data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
  142. data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
  143. data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
  144. data/lib/superkick/integrations/shortcut.rb +11 -0
  145. data/lib/superkick/integrations/slack/README.md +297 -0
  146. data/lib/superkick/integrations/slack/drops.rb +70 -0
  147. data/lib/superkick/integrations/slack/notifier.rb +426 -0
  148. data/lib/superkick/integrations/slack/spawner.rb +251 -0
  149. data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
  150. data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
  151. data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
  152. data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
  153. data/lib/superkick/integrations/slack.rb +12 -0
  154. data/lib/superkick/liquid.rb +129 -0
  155. data/lib/superkick/local/repository_source.rb +148 -0
  156. data/lib/superkick/mcp_server.rb +596 -0
  157. data/lib/superkick/monitor.rb +215 -0
  158. data/lib/superkick/notification_dispatcher.rb +280 -0
  159. data/lib/superkick/notifier.rb +173 -0
  160. data/lib/superkick/notifier_state_store.rb +55 -0
  161. data/lib/superkick/notifier_template.rb +121 -0
  162. data/lib/superkick/notifiers/command.rb +124 -0
  163. data/lib/superkick/notifiers/terminal_bell.rb +41 -0
  164. data/lib/superkick/output_logger.rb +54 -0
  165. data/lib/superkick/poller.rb +126 -0
  166. data/lib/superkick/process_runner.rb +87 -0
  167. data/lib/superkick/pty_proxy.rb +403 -0
  168. data/lib/superkick/registry.rb +75 -0
  169. data/lib/superkick/repository_source.rb +195 -0
  170. data/lib/superkick/server.rb +211 -0
  171. data/lib/superkick/session_recorder.rb +154 -0
  172. data/lib/superkick/setup.rb +160 -0
  173. data/lib/superkick/spawn/agent_spawner.rb +311 -0
  174. data/lib/superkick/spawn/approval_store.rb +113 -0
  175. data/lib/superkick/spawn/handler.rb +144 -0
  176. data/lib/superkick/spawn/injector.rb +119 -0
  177. data/lib/superkick/spawn/workflow_executor.rb +196 -0
  178. data/lib/superkick/spawn/workflow_validator.rb +77 -0
  179. data/lib/superkick/spawner.rb +67 -0
  180. data/lib/superkick/supervisor.rb +516 -0
  181. data/lib/superkick/team/artifact_store.rb +92 -0
  182. data/lib/superkick/team/log.rb +140 -0
  183. data/lib/superkick/team/log_entry_drop.rb +34 -0
  184. data/lib/superkick/team/log_monitor.rb +84 -0
  185. data/lib/superkick/team/log_notifier.rb +96 -0
  186. data/lib/superkick/team/log_store.rb +40 -0
  187. data/lib/superkick/template_filters.rb +24 -0
  188. data/lib/superkick/template_renderer.rb +223 -0
  189. data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
  190. data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
  191. data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
  192. data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
  193. data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
  194. data/lib/superkick/version.rb +5 -0
  195. data/lib/superkick/version_control.rb +135 -0
  196. data/lib/superkick/yaml_config.rb +302 -0
  197. data/lib/superkick.rb +198 -0
  198. data/plan.md +267 -0
  199. 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,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "docker/client"
4
+ require_relative "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