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,313 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Hosted
|
|
5
|
+
module Attach
|
|
6
|
+
# Server-side relay/mux for remote attach sessions over raw WebSocket.
|
|
7
|
+
#
|
|
8
|
+
# Manages one agent WebSocket and multiple user WebSockets, routing
|
|
9
|
+
# Attach::Protocol binary frames between them. The relay enforces RW
|
|
10
|
+
# exclusivity, buffers output for replay on new connections, and handles
|
|
11
|
+
# idle timeout auto-demotion.
|
|
12
|
+
#
|
|
13
|
+
# WebSocket objects must respond to #send_binary(data) and #close.
|
|
14
|
+
# The relay is transport-agnostic — the hosted server wraps its WebSocket
|
|
15
|
+
# library to provide this interface.
|
|
16
|
+
class Relay
|
|
17
|
+
DEFAULT_REPLAY_BUFFER_SIZE = 65_536 # 64 KB
|
|
18
|
+
DEFAULT_MAX_CONNECTIONS = 10
|
|
19
|
+
DEFAULT_RW_IDLE_TIMEOUT = 300 # 5 minutes
|
|
20
|
+
|
|
21
|
+
def initialize(agent_id:, config: {})
|
|
22
|
+
@agent_id = agent_id
|
|
23
|
+
@replay_buffer_size = config.fetch(:replay_buffer_size, DEFAULT_REPLAY_BUFFER_SIZE).to_i
|
|
24
|
+
@max_connections = config.fetch(:max_connections, DEFAULT_MAX_CONNECTIONS).to_i
|
|
25
|
+
@rw_idle_timeout = config.fetch(:rw_idle_timeout, DEFAULT_RW_IDLE_TIMEOUT)
|
|
26
|
+
|
|
27
|
+
@agent_ws = nil
|
|
28
|
+
@users = [] # Array of { websocket:, mode: }
|
|
29
|
+
@rw_user = nil # The current RW user websocket (or nil)
|
|
30
|
+
@rw_last_input_at = nil
|
|
31
|
+
|
|
32
|
+
@mutex = Mutex.new
|
|
33
|
+
@history = HistoryBuffer.new(capacity: @replay_buffer_size)
|
|
34
|
+
@idle_check_thread = nil
|
|
35
|
+
|
|
36
|
+
start_idle_check if @rw_idle_timeout && @rw_idle_timeout > 0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# --- Agent connection ---
|
|
40
|
+
|
|
41
|
+
# Called when the agent connects via WebSocket.
|
|
42
|
+
def attach_agent(websocket)
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
@agent_ws = websocket
|
|
45
|
+
end
|
|
46
|
+
Superkick.logger.info("attach:relay") { "Agent #{@agent_id} attached" }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Called when the agent WebSocket closes.
|
|
50
|
+
def detach_agent
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
@agent_ws = nil
|
|
53
|
+
|
|
54
|
+
# Notify all users that the agent disconnected
|
|
55
|
+
error_frame = Superkick::Attach::Protocol.build_frame(
|
|
56
|
+
Superkick::Attach::Protocol::ERROR,
|
|
57
|
+
JSON.generate({message: "Agent disconnected"})
|
|
58
|
+
)
|
|
59
|
+
@users.each do |user|
|
|
60
|
+
user[:websocket].send_binary(error_frame)
|
|
61
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
Superkick.logger.info("attach:relay") { "Agent #{@agent_id} detached" }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def agent_connected?
|
|
69
|
+
@mutex.synchronize { !@agent_ws.nil? }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# --- User connections ---
|
|
73
|
+
|
|
74
|
+
# Add a user connection. Enforces RW exclusivity and max connections.
|
|
75
|
+
# Sends META frame and replays history.
|
|
76
|
+
def add_user(websocket, mode:, force: false)
|
|
77
|
+
@mutex.synchronize do
|
|
78
|
+
# Enforce max connections
|
|
79
|
+
if @users.size >= @max_connections
|
|
80
|
+
error_frame = Superkick::Attach::Protocol.build_frame(
|
|
81
|
+
Superkick::Attach::Protocol::ERROR,
|
|
82
|
+
JSON.generate({message: "Maximum connections reached"})
|
|
83
|
+
)
|
|
84
|
+
websocket.send_binary(error_frame)
|
|
85
|
+
websocket.close
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Enforce RW exclusivity
|
|
90
|
+
if mode == :rw
|
|
91
|
+
if @rw_user
|
|
92
|
+
if force
|
|
93
|
+
demote_rw_user_locked("Read-write taken over by another client")
|
|
94
|
+
else
|
|
95
|
+
error_frame = Superkick::Attach::Protocol.build_frame(
|
|
96
|
+
Superkick::Attach::Protocol::ERROR,
|
|
97
|
+
JSON.generate({message: "Another read-write client is already connected"})
|
|
98
|
+
)
|
|
99
|
+
websocket.send_binary(error_frame)
|
|
100
|
+
websocket.close
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
@rw_user = websocket
|
|
105
|
+
@rw_last_input_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
@users << {websocket:, mode:}
|
|
109
|
+
|
|
110
|
+
# Send META
|
|
111
|
+
meta_frame = Superkick::Attach::Protocol.build_frame(
|
|
112
|
+
Superkick::Attach::Protocol::META,
|
|
113
|
+
JSON.generate({agent_id: @agent_id, history_bytes: @history.size})
|
|
114
|
+
)
|
|
115
|
+
websocket.send_binary(meta_frame)
|
|
116
|
+
|
|
117
|
+
# Replay history
|
|
118
|
+
history_data = @history.snapshot
|
|
119
|
+
unless history_data.empty?
|
|
120
|
+
history_frame = Superkick::Attach::Protocol.build_frame(Superkick::Attach::Protocol::HISTORY, history_data)
|
|
121
|
+
websocket.send_binary(history_frame)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
Superkick.logger.info("attach:relay") { "User added to #{@agent_id} (#{mode})" }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Remove a user connection.
|
|
128
|
+
def remove_user(websocket)
|
|
129
|
+
@mutex.synchronize do
|
|
130
|
+
@users.reject! { it[:websocket] == websocket }
|
|
131
|
+
if @rw_user == websocket
|
|
132
|
+
@rw_user = nil
|
|
133
|
+
@rw_last_input_at = nil
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
Superkick.logger.debug("attach:relay") { "User removed from #{@agent_id}" }
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# --- Frame routing ---
|
|
140
|
+
|
|
141
|
+
# Handle a binary frame from the agent. Parses the frame type,
|
|
142
|
+
# updates history for OUTPUT, and broadcasts to all users.
|
|
143
|
+
def handle_agent_frame(data)
|
|
144
|
+
data = data.b if data.is_a?(String) && data.encoding != Encoding::BINARY
|
|
145
|
+
return if data.bytesize < 5
|
|
146
|
+
|
|
147
|
+
type = data.getbyte(0)
|
|
148
|
+
|
|
149
|
+
# Buffer OUTPUT frames in history for replay
|
|
150
|
+
if type == Superkick::Attach::Protocol::OUTPUT
|
|
151
|
+
payload_len = data[1, 4].unpack1("N")
|
|
152
|
+
payload = data[5, payload_len]
|
|
153
|
+
@history.write(payload) if payload
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Broadcast to all users
|
|
157
|
+
@mutex.synchronize do
|
|
158
|
+
@users.reject! do |user|
|
|
159
|
+
user[:websocket].send_binary(data)
|
|
160
|
+
false
|
|
161
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
162
|
+
@rw_user = nil if @rw_user == user[:websocket]
|
|
163
|
+
true # remove disconnected user
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Handle a binary frame from a user. Parses the frame type and routes:
|
|
169
|
+
# - INPUT/RESIZE: forward to agent (RW user only)
|
|
170
|
+
# - COMMAND: handle mode switching locally
|
|
171
|
+
def handle_user_frame(websocket, data)
|
|
172
|
+
data = data.b if data.is_a?(String) && data.encoding != Encoding::BINARY
|
|
173
|
+
return if data.bytesize < 5
|
|
174
|
+
|
|
175
|
+
type = data.getbyte(0)
|
|
176
|
+
|
|
177
|
+
case type
|
|
178
|
+
when Superkick::Attach::Protocol::INPUT
|
|
179
|
+
@mutex.synchronize do
|
|
180
|
+
if @rw_user == websocket
|
|
181
|
+
@rw_last_input_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
182
|
+
@agent_ws&.send_binary(data)
|
|
183
|
+
end
|
|
184
|
+
# Silently drop INPUT from non-RW users
|
|
185
|
+
end
|
|
186
|
+
when Superkick::Attach::Protocol::RESIZE
|
|
187
|
+
@mutex.synchronize do
|
|
188
|
+
@agent_ws&.send_binary(data) if @rw_user == websocket
|
|
189
|
+
end
|
|
190
|
+
when Superkick::Attach::Protocol::COMMAND
|
|
191
|
+
payload_len = data[1, 4].unpack1("N")
|
|
192
|
+
payload = data[5, payload_len]
|
|
193
|
+
cmd = Superkick::Attach::Protocol.decode_json(payload) if payload
|
|
194
|
+
handle_command(websocket, cmd) if cmd
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# --- Lifecycle ---
|
|
199
|
+
|
|
200
|
+
def stop
|
|
201
|
+
@idle_check_thread&.kill
|
|
202
|
+
rescue ThreadError
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def user_count
|
|
207
|
+
@mutex.synchronize { @users.size }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def rw_user
|
|
211
|
+
@mutex.synchronize { @rw_user }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
private
|
|
215
|
+
|
|
216
|
+
def handle_command(websocket, cmd)
|
|
217
|
+
case cmd[:action]
|
|
218
|
+
when "promote_rw"
|
|
219
|
+
@mutex.synchronize do
|
|
220
|
+
if @rw_user == websocket
|
|
221
|
+
notify_user(websocket, "Already in read-write mode")
|
|
222
|
+
elsif @rw_user
|
|
223
|
+
notify_user(websocket, "Read-write slot is occupied — use forced promotion to take over")
|
|
224
|
+
else
|
|
225
|
+
promote_user_locked(websocket)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
when "force_promote_rw"
|
|
229
|
+
@mutex.synchronize do
|
|
230
|
+
if @rw_user == websocket
|
|
231
|
+
notify_user(websocket, "Already in read-write mode")
|
|
232
|
+
else
|
|
233
|
+
demote_rw_user_locked("Read-write taken over by another client") if @rw_user
|
|
234
|
+
promote_user_locked(websocket)
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
when "demote_ro"
|
|
238
|
+
@mutex.synchronize do
|
|
239
|
+
if @rw_user == websocket
|
|
240
|
+
@rw_user = nil
|
|
241
|
+
@rw_last_input_at = nil
|
|
242
|
+
entry = @users.find { it[:websocket] == websocket }
|
|
243
|
+
entry[:mode] = :ro if entry
|
|
244
|
+
notify_user(websocket, "Switched to read-only mode")
|
|
245
|
+
else
|
|
246
|
+
notify_user(websocket, "Already in read-only mode")
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
when "claim", "unclaim"
|
|
250
|
+
# Forward claim/unclaim to agent — these are control plane commands
|
|
251
|
+
@mutex.synchronize do
|
|
252
|
+
@agent_ws&.send_binary(Superkick::Attach::Protocol.build_frame(
|
|
253
|
+
Superkick::Attach::Protocol::COMMAND, JSON.generate(cmd)
|
|
254
|
+
))
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Promote a user to RW. MUST be called while holding @mutex.
|
|
260
|
+
def promote_user_locked(websocket)
|
|
261
|
+
@rw_user = websocket
|
|
262
|
+
@rw_last_input_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
263
|
+
entry = @users.find { it[:websocket] == websocket }
|
|
264
|
+
entry[:mode] = :rw if entry
|
|
265
|
+
notify_user(websocket, "Promoted to read-write mode")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Demote the current RW user. MUST be called while holding @mutex.
|
|
269
|
+
def demote_rw_user_locked(reason)
|
|
270
|
+
old = @rw_user
|
|
271
|
+
return unless old
|
|
272
|
+
|
|
273
|
+
@rw_user = nil
|
|
274
|
+
@rw_last_input_at = nil
|
|
275
|
+
entry = @users.find { it[:websocket] == old }
|
|
276
|
+
entry[:mode] = :ro if entry
|
|
277
|
+
notify_user(old, reason)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def notify_user(websocket, message)
|
|
281
|
+
frame = Superkick::Attach::Protocol.build_frame(
|
|
282
|
+
Superkick::Attach::Protocol::NOTIFY,
|
|
283
|
+
JSON.generate({message:})
|
|
284
|
+
)
|
|
285
|
+
websocket.send_binary(frame)
|
|
286
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
287
|
+
nil
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def start_idle_check
|
|
291
|
+
@idle_check_thread = Thread.new do
|
|
292
|
+
check_interval = [@rw_idle_timeout / 2.0, 30].min
|
|
293
|
+
loop do
|
|
294
|
+
sleep check_interval
|
|
295
|
+
@mutex.synchronize do
|
|
296
|
+
next unless @rw_user && @rw_last_input_at
|
|
297
|
+
|
|
298
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - @rw_last_input_at
|
|
299
|
+
if elapsed >= @rw_idle_timeout
|
|
300
|
+
demote_rw_user_locked(
|
|
301
|
+
"Demoted to read-only after #{@rw_idle_timeout.to_i}s of inactivity"
|
|
302
|
+
)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
rescue => e
|
|
306
|
+
Superkick.logger.error("attach:relay") { "Idle check error: #{e.message}" }
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Hosted
|
|
5
|
+
module Attach
|
|
6
|
+
# Thread-safe registry of Attach::Relay instances, one per agent.
|
|
7
|
+
# Created at server startup in hosted mode. The hosted server wires
|
|
8
|
+
# WebSocket connections to relays via get_or_create on agent connect
|
|
9
|
+
# and remove on permanent disconnect.
|
|
10
|
+
class RelayStore
|
|
11
|
+
def initialize(config: {})
|
|
12
|
+
@config = config
|
|
13
|
+
@relays = {}
|
|
14
|
+
@mutex = Mutex.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Get or create a relay for the given agent.
|
|
18
|
+
def get_or_create(agent_id)
|
|
19
|
+
@mutex.synchronize do
|
|
20
|
+
@relays[agent_id] ||= Relay.new(agent_id:, config: @config)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Get an existing relay (nil if none).
|
|
25
|
+
def get(agent_id)
|
|
26
|
+
@mutex.synchronize { @relays[agent_id] }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Remove a relay when the agent disconnects permanently.
|
|
30
|
+
# Calls stop on the relay to clean up the idle timeout thread.
|
|
31
|
+
def remove(agent_id)
|
|
32
|
+
relay = @mutex.synchronize { @relays.delete(agent_id) }
|
|
33
|
+
relay&.stop
|
|
34
|
+
relay
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Iterate over all relays.
|
|
38
|
+
def each(&block)
|
|
39
|
+
@mutex.synchronize { @relays.each_value(&block) }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def size
|
|
43
|
+
@mutex.synchronize { @relays.size }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Hosted
|
|
7
|
+
# Abstract base class for agent-side WebSocket bridges that connect local
|
|
8
|
+
# agent components to the hosted server over a persistent WebSocket.
|
|
9
|
+
#
|
|
10
|
+
# Provides the shared WebSocket lifecycle: TCP/TLS connect, auth handshake,
|
|
11
|
+
# read loop, and reconnection with exponential backoff. Subclasses override
|
|
12
|
+
# hook methods to handle channel-specific messages.
|
|
13
|
+
#
|
|
14
|
+
# Uses a simple raw WebSocket protocol (NOT ActionCable):
|
|
15
|
+
# 1. Client connects to a WebSocket endpoint
|
|
16
|
+
# 2. Client sends auth JSON text frame: {"type":"auth","agent_id":"...","api_key":"..."}
|
|
17
|
+
# 3. Server responds: {"type":"welcome"} or {"type":"error","message":"..."}
|
|
18
|
+
# 4. After auth, data frames flow (text or binary depending on channel)
|
|
19
|
+
#
|
|
20
|
+
# Subclasses implement:
|
|
21
|
+
# - websocket_path → URL path (e.g. "/api/v1/agents/#{@agent_id}/buffer/ws")
|
|
22
|
+
# - channel_name → log tag (e.g. "buffer:ws")
|
|
23
|
+
# - handle_server_message(message) → process parsed JSON text messages
|
|
24
|
+
# - handle_binary_message(data) → process binary messages (optional)
|
|
25
|
+
# - on_authenticated → called after welcome (optional)
|
|
26
|
+
class Bridge
|
|
27
|
+
RECONNECT_DELAYS = [1, 2, 4, 8, 16, 30].freeze
|
|
28
|
+
PING_INTERVAL = 30 # seconds
|
|
29
|
+
|
|
30
|
+
def initialize(agent_id:, server_url:, api_key:)
|
|
31
|
+
@agent_id = agent_id
|
|
32
|
+
@server_url = server_url.chomp("/")
|
|
33
|
+
@api_key = api_key
|
|
34
|
+
@thread = nil
|
|
35
|
+
@stop = false
|
|
36
|
+
@reconnect_attempt = 0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def start
|
|
40
|
+
@stop = false
|
|
41
|
+
@thread = Thread.new { run_loop }
|
|
42
|
+
self
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stop
|
|
46
|
+
@stop = true
|
|
47
|
+
@driver&.close
|
|
48
|
+
@socket&.close
|
|
49
|
+
@thread&.join(5)
|
|
50
|
+
rescue IOError, Errno::EBADF
|
|
51
|
+
nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Subclass hooks — override as needed.
|
|
57
|
+
|
|
58
|
+
def websocket_path
|
|
59
|
+
raise NotImplementedError, "#{self.class} must implement #websocket_path"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def channel_name
|
|
63
|
+
raise NotImplementedError, "#{self.class} must implement #channel_name"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Called for each parsed JSON text message after auth is complete.
|
|
67
|
+
def handle_server_message(message)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Called for each binary WebSocket message. Override for binary channels.
|
|
71
|
+
def handle_binary_message(data)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Called once after the server sends a welcome response.
|
|
75
|
+
def on_authenticated
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Sending helpers for subclasses.
|
|
79
|
+
|
|
80
|
+
def send_text(data)
|
|
81
|
+
return unless @driver
|
|
82
|
+
|
|
83
|
+
@driver.text(data)
|
|
84
|
+
flush_write_buffer
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def send_binary(data)
|
|
88
|
+
return unless @driver
|
|
89
|
+
|
|
90
|
+
@driver.binary(data)
|
|
91
|
+
flush_write_buffer
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Internal lifecycle.
|
|
95
|
+
|
|
96
|
+
def run_loop
|
|
97
|
+
until @stop
|
|
98
|
+
connect_and_serve
|
|
99
|
+
break if @stop
|
|
100
|
+
|
|
101
|
+
delay = RECONNECT_DELAYS.fetch(@reconnect_attempt) { RECONNECT_DELAYS.last }
|
|
102
|
+
Superkick.logger.info(channel_name) { "Reconnecting in #{delay}s (attempt #{@reconnect_attempt + 1})..." }
|
|
103
|
+
sleep(delay)
|
|
104
|
+
@reconnect_attempt += 1
|
|
105
|
+
end
|
|
106
|
+
rescue => e
|
|
107
|
+
Superkick.logger.error(channel_name) { "WebSocket loop error: #{e.message}" }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def connect_and_serve
|
|
111
|
+
uri = URI.parse("#{@server_url}#{websocket_path}")
|
|
112
|
+
@socket = open_socket(uri)
|
|
113
|
+
@driver = create_driver(uri)
|
|
114
|
+
|
|
115
|
+
setup_driver_handlers
|
|
116
|
+
start_driver
|
|
117
|
+
|
|
118
|
+
# Send auth frame
|
|
119
|
+
authenticate
|
|
120
|
+
|
|
121
|
+
# Read loop — blocks until connection closes
|
|
122
|
+
read_loop
|
|
123
|
+
|
|
124
|
+
@reconnect_attempt = 0 # successful connection resets backoff
|
|
125
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::ETIMEDOUT,
|
|
126
|
+
Errno::EHOSTUNREACH, Errno::ENETUNREACH, IOError, OpenSSL::SSL::SSLError => e
|
|
127
|
+
Superkick.logger.warn(channel_name) { "Connection failed: #{e.message}" }
|
|
128
|
+
ensure
|
|
129
|
+
begin
|
|
130
|
+
@socket&.close
|
|
131
|
+
rescue IOError, Errno::EBADF
|
|
132
|
+
nil
|
|
133
|
+
end
|
|
134
|
+
@driver = nil
|
|
135
|
+
@socket = nil
|
|
136
|
+
@authenticated = false
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def open_socket(uri)
|
|
140
|
+
default_port = (uri.scheme == "wss") ? 443 : 80
|
|
141
|
+
port = uri.port || default_port
|
|
142
|
+
tcp = TCPSocket.new(uri.host, port)
|
|
143
|
+
|
|
144
|
+
if uri.scheme == "wss"
|
|
145
|
+
require "openssl"
|
|
146
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
147
|
+
ctx.set_params(verify_mode: OpenSSL::SSL::VERIFY_PEER)
|
|
148
|
+
ssl = OpenSSL::SSL::SSLSocket.new(tcp, ctx)
|
|
149
|
+
ssl.hostname = uri.host
|
|
150
|
+
ssl.connect
|
|
151
|
+
ssl
|
|
152
|
+
else
|
|
153
|
+
tcp
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def create_driver(uri)
|
|
158
|
+
require "websocket/driver"
|
|
159
|
+
|
|
160
|
+
socket_wrapper = SocketWrapper.new(@socket, uri.to_s)
|
|
161
|
+
driver = WebSocket::Driver.client(socket_wrapper)
|
|
162
|
+
driver.set_header("Authorization", "Bearer #{@api_key}")
|
|
163
|
+
driver
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def setup_driver_handlers
|
|
167
|
+
@authenticated = false
|
|
168
|
+
|
|
169
|
+
@driver.on :open do
|
|
170
|
+
Superkick.logger.info(channel_name) { "WebSocket connected for #{@agent_id}" }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
@driver.on :message do |event|
|
|
174
|
+
dispatch_text_message(event.data)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
@driver.on :binary do |event|
|
|
178
|
+
handle_binary_message(event.data)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
@driver.on :close do
|
|
182
|
+
Superkick.logger.info(channel_name) { "WebSocket closed for #{@agent_id}" }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
@driver.on :error do |event|
|
|
186
|
+
Superkick.logger.error(channel_name) { "WebSocket error: #{event.message}" }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def start_driver
|
|
191
|
+
@driver.start
|
|
192
|
+
flush_write_buffer
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def authenticate
|
|
196
|
+
auth = {type: "auth", agent_id: @agent_id, api_key: @api_key}
|
|
197
|
+
send_text(JSON.generate(auth))
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def read_loop
|
|
201
|
+
until @stop
|
|
202
|
+
data = begin
|
|
203
|
+
@socket.read_nonblock(4096)
|
|
204
|
+
rescue IO::WaitReadable
|
|
205
|
+
IO.select([@socket], nil, nil, PING_INTERVAL)
|
|
206
|
+
retry unless @stop
|
|
207
|
+
break
|
|
208
|
+
rescue IOError, Errno::ECONNRESET, Errno::EBADF
|
|
209
|
+
break
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
break unless data && !data.empty?
|
|
213
|
+
|
|
214
|
+
@driver.parse(data)
|
|
215
|
+
flush_write_buffer
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def dispatch_text_message(data)
|
|
220
|
+
message = JSON.parse(data, symbolize_names: true)
|
|
221
|
+
|
|
222
|
+
case message[:type]
|
|
223
|
+
when "welcome"
|
|
224
|
+
@authenticated = true
|
|
225
|
+
Superkick.logger.info(channel_name) { "Authenticated for #{@agent_id}" }
|
|
226
|
+
on_authenticated
|
|
227
|
+
when "error"
|
|
228
|
+
Superkick.logger.error(channel_name) { "Server error: #{message[:message]}" }
|
|
229
|
+
when "ping"
|
|
230
|
+
send_text(JSON.generate({type: "pong"}))
|
|
231
|
+
when "disconnect"
|
|
232
|
+
Superkick.logger.info(channel_name) { "Server requested disconnect: #{message[:reason]}" }
|
|
233
|
+
else
|
|
234
|
+
handle_server_message(message) if @authenticated
|
|
235
|
+
end
|
|
236
|
+
rescue JSON::ParserError => e
|
|
237
|
+
Superkick.logger.warn(channel_name) { "Invalid JSON from server: #{e.message}" }
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def flush_write_buffer
|
|
241
|
+
# websocket-driver writes to the SocketWrapper, which writes directly
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Minimal wrapper that websocket-driver requires.
|
|
245
|
+
# Must respond to #url and #write.
|
|
246
|
+
class SocketWrapper
|
|
247
|
+
attr_reader :url
|
|
248
|
+
|
|
249
|
+
def initialize(socket, url)
|
|
250
|
+
@socket = socket
|
|
251
|
+
@url = url
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def write(data)
|
|
255
|
+
@socket.write(data)
|
|
256
|
+
@socket.flush
|
|
257
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
258
|
+
nil
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Hosted
|
|
5
|
+
module Buffer
|
|
6
|
+
# Agent-side bridge that connects the local Buffer::Server to the hosted
|
|
7
|
+
# server over a persistent WebSocket. Commands from the server
|
|
8
|
+
# (enqueue_injection, idle_state, etc.) arrive as JSON text frames and are
|
|
9
|
+
# dispatched to the local Buffer::Server command handlers. Responses flow
|
|
10
|
+
# back the same way.
|
|
11
|
+
#
|
|
12
|
+
# Subclass of Superkick::Hosted::Bridge — inherits the WebSocket lifecycle,
|
|
13
|
+
# auth handshake, read loop, and reconnection with exponential backoff.
|
|
14
|
+
class Bridge < Superkick::Hosted::Bridge
|
|
15
|
+
def initialize(agent_id:, server_url:, api_key:, command_handler:)
|
|
16
|
+
super(agent_id:, server_url:, api_key:)
|
|
17
|
+
@command_handler = command_handler
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def websocket_path
|
|
23
|
+
"/api/v1/agents/#{@agent_id}/buffer/ws"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def channel_name
|
|
27
|
+
"buffer:ws"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def handle_server_message(message)
|
|
31
|
+
response = @command_handler.call(message)
|
|
32
|
+
|
|
33
|
+
# If the command had a request_id, send the response back
|
|
34
|
+
if message[:request_id] && response
|
|
35
|
+
response[:request_id] = message[:request_id]
|
|
36
|
+
send_text(JSON.generate(response))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|