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,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
# Orchestrates a single injection attempt by sending a rendered prompt to
|
|
7
|
+
# the agent's InjectionQueue via the buffer client.
|
|
8
|
+
#
|
|
9
|
+
# The server no longer checks preconditions (idle state, guards) — the
|
|
10
|
+
# agent's InjectionQueue handles all gating locally with zero latency.
|
|
11
|
+
#
|
|
12
|
+
# Returns :enqueued or :skipped.
|
|
13
|
+
class Injector
|
|
14
|
+
def initialize(store:, notification_dispatcher:, buffer_client: nil, config: Superkick.config)
|
|
15
|
+
@store = store
|
|
16
|
+
@config = config
|
|
17
|
+
@notification_dispatcher = notification_dispatcher
|
|
18
|
+
@buffer_client = buffer_client || Buffer.client_from(store:, config:)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def inject(agent_id:, event:)
|
|
22
|
+
unless @buffer_client.reachable?(agent_id)
|
|
23
|
+
Superkick.logger.debug("injector") { "Agent #{agent_id} not reachable — skipping" }
|
|
24
|
+
return :skipped
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
prompt_text = TemplateRenderer.render(event)
|
|
28
|
+
|
|
29
|
+
@buffer_client.send_command(agent_id, "enqueue_injection",
|
|
30
|
+
id: SecureRandom.uuid,
|
|
31
|
+
prompt: prompt_text,
|
|
32
|
+
monitor_type: event[:monitor_type],
|
|
33
|
+
monitor_name: event[:monitor_name],
|
|
34
|
+
priority: event[:injection_priority] || :normal,
|
|
35
|
+
ttl: event[:injection_ttl] || monitor_ttl(event),
|
|
36
|
+
supersede_key: event[:injection_supersede_key])
|
|
37
|
+
|
|
38
|
+
Superkick.logger.info("injector") { "Enqueued #{event[:event_type]} for agent #{agent_id}" }
|
|
39
|
+
:enqueued
|
|
40
|
+
rescue => e
|
|
41
|
+
Superkick.logger.error("injector") { "Injection error for #{agent_id}: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
|
|
42
|
+
:error
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def monitor_ttl(event)
|
|
48
|
+
# Allow per-monitor TTL override via monitor config
|
|
49
|
+
agent_id = event[:agent_id]
|
|
50
|
+
monitor_name = event[:monitor_name]
|
|
51
|
+
|
|
52
|
+
if agent_id && monitor_name
|
|
53
|
+
agent = @store.get(agent_id)
|
|
54
|
+
config = agent&.monitor_config(monitor_name)
|
|
55
|
+
return config[:injection_ttl] if config&.dig(:injection_ttl)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
InjectionQueue::DEFAULT_TTL
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def dispatch_notification(...)
|
|
62
|
+
@notification_dispatcher.dispatch(...)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Thread-safe buffer that tracks the user's current partial (unsubmitted)
|
|
5
|
+
# input and any active injection guards.
|
|
6
|
+
#
|
|
7
|
+
# Raw terminal byte sequences handled:
|
|
8
|
+
# - Printable UTF-8 chars → append to buffer
|
|
9
|
+
# - \x03 (C-c) → clear buffer, clear submit-on-clear guards
|
|
10
|
+
# - \r outside paste mode → clear buffer, clear submit-on-clear guards
|
|
11
|
+
# - \r inside paste mode → treated as \n (pasted newline, not submit)
|
|
12
|
+
# - \x7f / \x08 → backspace (remove last char)
|
|
13
|
+
# - \x1b[200~ / \x1b[201~ → bracketed paste start/end markers
|
|
14
|
+
# - Other ANSI escapes → discarded (not stored in buffer)
|
|
15
|
+
class InputBuffer
|
|
16
|
+
def initialize
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@buffer = "".dup
|
|
19
|
+
@paste_mode = false
|
|
20
|
+
# guards: { name_string => { reason: String, clear_on_submit: bool } }
|
|
21
|
+
@guards = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Feed raw bytes from the terminal into the buffer.
|
|
25
|
+
def append(bytes)
|
|
26
|
+
bytes = bytes.dup.force_encoding(Encoding::BINARY)
|
|
27
|
+
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
i = 0
|
|
30
|
+
while i < bytes.bytesize
|
|
31
|
+
b = bytes.byteslice(i, 1)
|
|
32
|
+
|
|
33
|
+
# Bracketed paste start: ESC [ 2 0 0 ~
|
|
34
|
+
if bytes.byteslice(i, 6) == "\x1b[200~"
|
|
35
|
+
@paste_mode = true
|
|
36
|
+
i += 6
|
|
37
|
+
next
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Bracketed paste end: ESC [ 2 0 1 ~
|
|
41
|
+
if bytes.byteslice(i, 6) == "\x1b[201~"
|
|
42
|
+
@paste_mode = false
|
|
43
|
+
i += 6
|
|
44
|
+
next
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# ANSI / escape sequences
|
|
48
|
+
if b == "\x1b"
|
|
49
|
+
nxt = bytes.byteslice(i + 1, 1)
|
|
50
|
+
if nxt == "["
|
|
51
|
+
# CSI sequence: skip parameter bytes then one final byte
|
|
52
|
+
j = i + 2
|
|
53
|
+
j += 1 while j < bytes.bytesize && bytes.byteslice(j, 1) =~ /[0-9;]/
|
|
54
|
+
i = j + 1
|
|
55
|
+
elsif nxt == "]"
|
|
56
|
+
# OSC: skip until BEL (\x07) or ST (ESC \); advance j past the terminator
|
|
57
|
+
j = i + 2
|
|
58
|
+
loop do
|
|
59
|
+
break if j >= bytes.bytesize
|
|
60
|
+
if bytes.byteslice(j, 1) == "\x07"
|
|
61
|
+
j += 1 # skip BEL
|
|
62
|
+
break
|
|
63
|
+
end
|
|
64
|
+
if bytes.byteslice(j, 1) == "\x1b" && bytes.byteslice(j + 1, 1) == "\\"
|
|
65
|
+
j += 2 # skip ESC \
|
|
66
|
+
break
|
|
67
|
+
end
|
|
68
|
+
j += 1
|
|
69
|
+
end
|
|
70
|
+
i = j
|
|
71
|
+
else
|
|
72
|
+
# Two-byte escape — skip
|
|
73
|
+
i += 2
|
|
74
|
+
end
|
|
75
|
+
next
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
ord = b.ord
|
|
79
|
+
|
|
80
|
+
# C-c → submit (clear buffer + guards)
|
|
81
|
+
if ord == 0x03
|
|
82
|
+
@buffer = "".dup
|
|
83
|
+
clear_submit_guards!
|
|
84
|
+
i += 1
|
|
85
|
+
next
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# CR → submit outside paste, newline inside paste
|
|
89
|
+
if ord == 0x0D
|
|
90
|
+
if @paste_mode
|
|
91
|
+
@buffer << "\n"
|
|
92
|
+
else
|
|
93
|
+
@buffer = "".dup
|
|
94
|
+
clear_submit_guards!
|
|
95
|
+
end
|
|
96
|
+
i += 1
|
|
97
|
+
next
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Backspace / DEL
|
|
101
|
+
if ord == 0x7F || ord == 0x08
|
|
102
|
+
@buffer.chop! unless @buffer.empty?
|
|
103
|
+
i += 1
|
|
104
|
+
next
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Printable (including high UTF-8 bytes)
|
|
108
|
+
if ord >= 0x20
|
|
109
|
+
char_len = utf8_char_byte_length(ord)
|
|
110
|
+
@buffer << bytes.byteslice(i, char_len).force_encoding(Encoding::UTF_8)
|
|
111
|
+
i += char_len
|
|
112
|
+
next
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Everything else — skip
|
|
116
|
+
i += 1
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Returns the current partial input string (copy).
|
|
122
|
+
def contents
|
|
123
|
+
@mutex.synchronize { @buffer.dup }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Unconditionally clears the buffer (called by Buffer::Server on inject).
|
|
127
|
+
def clear
|
|
128
|
+
@mutex.synchronize do
|
|
129
|
+
@buffer = "".dup
|
|
130
|
+
@paste_mode = false
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# --- Injection guards --------------------------------------------------
|
|
135
|
+
|
|
136
|
+
def set_guard(name, reason, clear_on_submit: true)
|
|
137
|
+
@mutex.synchronize do
|
|
138
|
+
@guards[name.to_s] = {reason:, clear_on_submit:}
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def clear_guard(name)
|
|
143
|
+
@mutex.synchronize { @guards.delete(name.to_s) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Returns { name_string => reason_string } for active guards.
|
|
147
|
+
def guards
|
|
148
|
+
@mutex.synchronize do
|
|
149
|
+
@guards.transform_values { it[:reason] }
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def guards_active?
|
|
154
|
+
@mutex.synchronize { !@guards.empty? }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def clear_submit_guards!
|
|
160
|
+
@guards.reject! { |_, v| v[:clear_on_submit] }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def utf8_char_byte_length(first_byte_ord)
|
|
164
|
+
if first_byte_ord >= 0xF0 then 4
|
|
165
|
+
elsif first_byte_ord >= 0xE0 then 3
|
|
166
|
+
elsif first_byte_ord >= 0xC0 then 2
|
|
167
|
+
else 1
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Bugsnag Spawner
|
|
2
|
+
|
|
3
|
+
Type: `:bugsnag`
|
|
4
|
+
|
|
5
|
+
Watches Bugsnag for open errors and spawns AI coding agents to fix them. Polls
|
|
6
|
+
the Bugsnag Data Access API for errors matching configurable filters (severity,
|
|
7
|
+
release stage, error class, version, minimum event count).
|
|
8
|
+
|
|
9
|
+
Errors are tracked by ID to prevent re-dispatching. Errors that fail client-side
|
|
10
|
+
filters (e.g. below `minimum_events`) are retried on subsequent ticks so they
|
|
11
|
+
fire once the threshold is crossed.
|
|
12
|
+
|
|
13
|
+
## Configuration
|
|
14
|
+
|
|
15
|
+
```yaml
|
|
16
|
+
spawners:
|
|
17
|
+
bugsnag:
|
|
18
|
+
type: bugsnag
|
|
19
|
+
project_id: "abc123def456"
|
|
20
|
+
token: <%= env("BUGSNAG_TOKEN") %>
|
|
21
|
+
driver: claude_code
|
|
22
|
+
severity:
|
|
23
|
+
- error
|
|
24
|
+
release_stage: production
|
|
25
|
+
minimum_events: 5
|
|
26
|
+
error_classes:
|
|
27
|
+
include:
|
|
28
|
+
- NoMethodError
|
|
29
|
+
- RuntimeError
|
|
30
|
+
exclude:
|
|
31
|
+
- Net::ReadTimeout
|
|
32
|
+
versions:
|
|
33
|
+
- latest
|
|
34
|
+
filters:
|
|
35
|
+
app.release_stage:
|
|
36
|
+
- production
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
| Key | Required | Default | Description |
|
|
40
|
+
|-----|----------|---------|-------------|
|
|
41
|
+
| `project_id` | yes | — | Bugsnag project ID |
|
|
42
|
+
| `token` | no | `$BUGSNAG_TOKEN` | Bugsnag API auth token |
|
|
43
|
+
| `severity` | no | `["error"]` | Severity filter (array or include/exclude hash) |
|
|
44
|
+
| `release_stage` | no | `"production"` | Release stage name |
|
|
45
|
+
| `minimum_events` | no | `1` | Minimum event count before dispatching |
|
|
46
|
+
| `error_classes` | no | — | Error class filter (array or include/exclude hash) |
|
|
47
|
+
| `versions` | no | — | Version filter (array or include/exclude hash; `"latest"` resolves via releases API) |
|
|
48
|
+
| `filters` | no | — | Additional Bugsnag API filter fields (hash, passed through directly) |
|
|
49
|
+
|
|
50
|
+
### List filter format
|
|
51
|
+
|
|
52
|
+
The `severity`, `error_classes`, and `versions` keys accept two formats:
|
|
53
|
+
|
|
54
|
+
- **Array** — treated as an include list: `["error", "warning"]`
|
|
55
|
+
- **Hash** — explicit include/exclude: `{ include: ["error"], exclude: ["info"] }`
|
|
56
|
+
|
|
57
|
+
Include filters are applied server-side (via the Bugsnag API). Exclude filters
|
|
58
|
+
are applied client-side after fetching.
|
|
59
|
+
|
|
60
|
+
## Agent ID format
|
|
61
|
+
|
|
62
|
+
```
|
|
63
|
+
bugsnag-error-{error_id}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Where `error_id` is the Bugsnag error ID. Dedup is handled by `AgentStore` —
|
|
67
|
+
if an agent with the same ID already exists, the spawn is skipped.
|
|
68
|
+
|
|
69
|
+
## Events
|
|
70
|
+
|
|
71
|
+
### `error_opened`
|
|
72
|
+
|
|
73
|
+
Dispatched when a new open error is found that passes all filters.
|
|
74
|
+
|
|
75
|
+
Template variables:
|
|
76
|
+
|
|
77
|
+
| Variable | Description |
|
|
78
|
+
|----------|-------------|
|
|
79
|
+
| `error_id` | Bugsnag error ID |
|
|
80
|
+
| `error_class` | Exception class name (e.g. `NoMethodError`) |
|
|
81
|
+
| `message` | Error message string |
|
|
82
|
+
| `severity` | Severity level (`error`, `warning`, `info`) |
|
|
83
|
+
| `status` | Bugsnag error status (e.g. `open`) |
|
|
84
|
+
| `events` | Total event count |
|
|
85
|
+
| `users` | Number of affected users |
|
|
86
|
+
| `first_seen` | Timestamp of first occurrence |
|
|
87
|
+
| `last_seen` | Timestamp of most recent occurrence |
|
|
88
|
+
| `release_stages` | Array of release stages where the error was seen |
|
|
89
|
+
| `url` | Bugsnag dashboard URL for this error |
|
|
90
|
+
| `project_url` | Bugsnag dashboard URL for the project |
|
|
91
|
+
| `project_id` | Bugsnag project ID |
|
|
92
|
+
|
|
93
|
+
## Error handling
|
|
94
|
+
|
|
95
|
+
- **401/403** — raises `FatalError`, stops the spawner (authentication failure)
|
|
96
|
+
- **429** — raises `RateLimited`, backs off per the standard poller backoff
|
|
97
|
+
- **404** — logged as warning, skipped (resource not found)
|
|
98
|
+
- Other HTTP errors — logged as warning, skipped
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Superkick
|
|
7
|
+
module Integrations
|
|
8
|
+
module Bugsnag
|
|
9
|
+
# Watches Bugsnag for open errors and spawns AI coding agents to fix them.
|
|
10
|
+
#
|
|
11
|
+
# Polls the Bugsnag Data Access API for errors matching configurable filters
|
|
12
|
+
# (severity, release stage, error class, event count, version). Tracks
|
|
13
|
+
# dispatched error IDs in-memory to avoid re-dispatching. Errors that
|
|
14
|
+
# fail client-side filters (e.g. below minimum_events) are retried on
|
|
15
|
+
# subsequent ticks so they fire once the threshold is crossed.
|
|
16
|
+
#
|
|
17
|
+
# Config keys:
|
|
18
|
+
# project_id (required) — Bugsnag project ID
|
|
19
|
+
# token (optional) — API auth token, falls back to $BUGSNAG_TOKEN
|
|
20
|
+
# severity (optional) — array or include/exclude hash, default ["error"]
|
|
21
|
+
# release_stage (optional) — release stage name, default "production"
|
|
22
|
+
# minimum_events (optional) — minimum event count to dispatch, default 1
|
|
23
|
+
# error_classes (optional) — array or include/exclude hash
|
|
24
|
+
# versions (optional) — array or include/exclude hash; "latest" resolves to newest release
|
|
25
|
+
# filters (optional) — hash of additional Bugsnag API filter fields
|
|
26
|
+
#
|
|
27
|
+
# List filter configs (severity, error_classes, versions) accept either form:
|
|
28
|
+
# - Array → treated as include list: ["error", "warning"]
|
|
29
|
+
# - Hash → include/exclude keys: { include: ["error"], exclude: ["info"] }
|
|
30
|
+
class Spawner < Superkick::Spawner
|
|
31
|
+
attr_reader :conn
|
|
32
|
+
|
|
33
|
+
API_BASE = "https://api.bugsnag.com"
|
|
34
|
+
DEFAULT_SEVERITY = %w[error].freeze
|
|
35
|
+
DEFAULT_RELEASE_STAGE = "production"
|
|
36
|
+
DEFAULT_MINIMUM_EVENTS = 1
|
|
37
|
+
PER_PAGE = 30
|
|
38
|
+
|
|
39
|
+
def self.type = :bugsnag
|
|
40
|
+
|
|
41
|
+
def self.description
|
|
42
|
+
"Watches Bugsnag for open errors and spawns AI coding agents to " \
|
|
43
|
+
"fix them. Supports filtering by severity, release stage, error " \
|
|
44
|
+
"class, version, and minimum event count."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.required_config = %i[project_id]
|
|
48
|
+
|
|
49
|
+
def self.spawn_templates_dir
|
|
50
|
+
File.join(__dir__, "templates")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.agent_id(event)
|
|
54
|
+
"bugsnag-error-#{event[:error_id]}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.setup_label = "Bugsnag"
|
|
58
|
+
|
|
59
|
+
def self.setup_config
|
|
60
|
+
<<~YAML
|
|
61
|
+
bugsnag:
|
|
62
|
+
type: bugsnag
|
|
63
|
+
project_id: "abc123def456"
|
|
64
|
+
token: <%= env("BUGSNAG_TOKEN") %>
|
|
65
|
+
# severity: # error severities to watch (default: [error])
|
|
66
|
+
# - error
|
|
67
|
+
# release_stage: production # default
|
|
68
|
+
# minimum_events: 1 # minimum occurrences before spawning
|
|
69
|
+
# max_duration: 3600
|
|
70
|
+
YAML
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def initialize(name:, config:, handler:, connection: nil)
|
|
74
|
+
super(name:, config:, handler:)
|
|
75
|
+
@conn = connection
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def tick
|
|
79
|
+
errors = fetch_errors
|
|
80
|
+
errors.each do |error|
|
|
81
|
+
next if filtered_by_severity?(error)
|
|
82
|
+
next if filtered_by_error_class?(error)
|
|
83
|
+
next if filtered_by_minimum_events?(error)
|
|
84
|
+
|
|
85
|
+
# Mark as seen only after passing all filters — an error below
|
|
86
|
+
# minimum_events today may cross the threshold on a future tick.
|
|
87
|
+
@seen_error_ids.add(error["id"])
|
|
88
|
+
|
|
89
|
+
dispatch(
|
|
90
|
+
event_type: :error_opened,
|
|
91
|
+
error_id: error["id"],
|
|
92
|
+
error_class: error["error_class"],
|
|
93
|
+
message: error["message"].to_s,
|
|
94
|
+
severity: error["severity"],
|
|
95
|
+
status: error["status"],
|
|
96
|
+
events: error["events"],
|
|
97
|
+
users: error["users_affected"] || error["users"],
|
|
98
|
+
first_seen: error["first_seen"],
|
|
99
|
+
last_seen: error["last_seen"],
|
|
100
|
+
release_stages: error["release_stages"],
|
|
101
|
+
url: error["url"],
|
|
102
|
+
project_url: error["project_url"],
|
|
103
|
+
project_id: self[:project_id]
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def on_start
|
|
109
|
+
@conn ||= build_connection
|
|
110
|
+
@seen_error_ids = Set.new
|
|
111
|
+
@latest_version = nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
# -- Bugsnag errors API ---------------------------------------------------
|
|
117
|
+
|
|
118
|
+
def fetch_errors
|
|
119
|
+
project_id = self[:project_id]
|
|
120
|
+
params = build_params
|
|
121
|
+
|
|
122
|
+
path = "/projects/#{project_id}/errors"
|
|
123
|
+
Superkick.logger.debug(log_tag) { "Fetching errors: #{path} #{params.inspect}" }
|
|
124
|
+
|
|
125
|
+
resp = get(path, params)
|
|
126
|
+
return [] unless resp
|
|
127
|
+
|
|
128
|
+
errors = resp.body
|
|
129
|
+
return [] unless errors.is_a?(Array)
|
|
130
|
+
|
|
131
|
+
# Filter out already-dispatched errors. New IDs are added to
|
|
132
|
+
# @seen_error_ids in tick, after client-side filters pass, so an
|
|
133
|
+
# error that was skipped (e.g. below minimum_events) gets retried.
|
|
134
|
+
new_errors = errors.reject { @seen_error_ids.include?(it["id"]) }
|
|
135
|
+
|
|
136
|
+
Superkick.logger.info(log_tag) { "Found #{errors.size} errors, #{new_errors.size} new" }
|
|
137
|
+
new_errors
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def build_params
|
|
141
|
+
params = {
|
|
142
|
+
"sort" => "last_seen",
|
|
143
|
+
"direction" => "desc",
|
|
144
|
+
"status" => "open",
|
|
145
|
+
"per_page" => PER_PAGE.to_s
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
filters = build_filters
|
|
149
|
+
params["filters"] = JSON.generate(filters) unless filters.empty?
|
|
150
|
+
|
|
151
|
+
params
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def build_filters
|
|
155
|
+
filters = {}
|
|
156
|
+
|
|
157
|
+
# Severity filter
|
|
158
|
+
sev = normalize_filter(self[:severity], default_include: DEFAULT_SEVERITY)
|
|
159
|
+
if sev[:include].any?
|
|
160
|
+
filters["event.severity"] = sev[:include].map { {type: "eq", value: it} }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Release stage filter
|
|
164
|
+
stage = self[:release_stage]
|
|
165
|
+
stage = DEFAULT_RELEASE_STAGE if stage.nil?
|
|
166
|
+
if stage && !stage.empty?
|
|
167
|
+
filters["app.release_stage"] = [{type: "eq", value: stage}]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Version filter (resolve "latest" to actual version string)
|
|
171
|
+
ver = normalize_filter(self[:versions])
|
|
172
|
+
if ver[:include].any?
|
|
173
|
+
resolved = resolve_versions(ver[:include])
|
|
174
|
+
if resolved.any?
|
|
175
|
+
filters["version.seen_in"] = resolved.map { {type: "eq", value: it} }
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Error class include filter (server-side)
|
|
180
|
+
ec = normalize_filter(self[:error_classes])
|
|
181
|
+
if ec[:include].any?
|
|
182
|
+
filters["event.class"] = ec[:include].map { {type: "eq", value: it} }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Merge in user-provided passthrough filters
|
|
186
|
+
if self[:filters].is_a?(Hash)
|
|
187
|
+
self[:filters].each do |field, values|
|
|
188
|
+
values = Array(values)
|
|
189
|
+
filters[field.to_s] = values.map { {type: "eq", value: it} }
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
filters
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def resolve_versions(versions)
|
|
197
|
+
versions.flat_map do |v|
|
|
198
|
+
if v.to_s.downcase == "latest"
|
|
199
|
+
latest = fetch_latest_version
|
|
200
|
+
latest ? [latest] : []
|
|
201
|
+
else
|
|
202
|
+
[v.to_s]
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def fetch_latest_version
|
|
208
|
+
return @latest_version if @latest_version
|
|
209
|
+
|
|
210
|
+
resp = get("/projects/#{self[:project_id]}/releases", {"per_page" => "1"})
|
|
211
|
+
return nil unless resp
|
|
212
|
+
|
|
213
|
+
releases = resp.body
|
|
214
|
+
return nil unless releases.is_a?(Array) && releases.any?
|
|
215
|
+
|
|
216
|
+
@latest_version = releases.first["release_group"] || releases.first["version"]
|
|
217
|
+
Superkick.logger.debug(log_tag) { "Resolved latest version: #{@latest_version}" }
|
|
218
|
+
@latest_version
|
|
219
|
+
rescue => e
|
|
220
|
+
Superkick.logger.warn(log_tag) { "Could not fetch latest version: #{e.message}" }
|
|
221
|
+
nil
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# -- Client-side filters --------------------------------------------------
|
|
225
|
+
|
|
226
|
+
def filtered_by_severity?(error)
|
|
227
|
+
excludes = normalize_filter(self[:severity])[:exclude]
|
|
228
|
+
return false unless excludes.any?
|
|
229
|
+
|
|
230
|
+
excludes.include?(error["severity"].to_s)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def filtered_by_error_class?(error)
|
|
234
|
+
excludes = normalize_filter(self[:error_classes])[:exclude]
|
|
235
|
+
return false unless excludes.any?
|
|
236
|
+
|
|
237
|
+
excludes.include?(error["error_class"].to_s)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def filtered_by_version?(error)
|
|
241
|
+
excludes = normalize_filter(self[:versions])[:exclude]
|
|
242
|
+
return false unless excludes.any?
|
|
243
|
+
|
|
244
|
+
# Version info isn't directly on the error object from the list endpoint,
|
|
245
|
+
# so version exclude is best-effort via the seen_in field if available.
|
|
246
|
+
seen_in = error["seen_in_versions"] || []
|
|
247
|
+
seen_in.any? { excludes.include?(it) }
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def filtered_by_minimum_events?(error)
|
|
251
|
+
min = self[:minimum_events] || DEFAULT_MINIMUM_EVENTS
|
|
252
|
+
count = error["events"] || 0
|
|
253
|
+
count < min
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# -- Filter normalization -------------------------------------------------
|
|
257
|
+
|
|
258
|
+
# Accepts either an Array (treated as include-only) or a Hash with
|
|
259
|
+
# :include / :exclude keys. Returns { include: [...], exclude: [...] }.
|
|
260
|
+
def normalize_filter(value, default_include: [])
|
|
261
|
+
case value
|
|
262
|
+
when Array
|
|
263
|
+
{include: value, exclude: []}
|
|
264
|
+
when Hash
|
|
265
|
+
{include: Array(value[:include]), exclude: Array(value[:exclude])}
|
|
266
|
+
else
|
|
267
|
+
{include: default_include, exclude: []}
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# -- HTTP -----------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def get(path, params = {})
|
|
274
|
+
resp = @conn.get(path) do |req|
|
|
275
|
+
params.each { |k, v| req.params[k] = v }
|
|
276
|
+
end
|
|
277
|
+
return nil unless handle_response!(resp)
|
|
278
|
+
resp
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Returns true on success, raises on auth/rate-limit, returns false on 404.
|
|
282
|
+
def handle_response!(resp)
|
|
283
|
+
case resp.status
|
|
284
|
+
when 200..299 then true
|
|
285
|
+
when 401, 403 then raise FatalError, "Bugsnag auth failed (HTTP #{resp.status})"
|
|
286
|
+
when 429 then raise RateLimited, "Bugsnag rate limited"
|
|
287
|
+
when 404
|
|
288
|
+
Superkick.logger.warn(log_tag) { "Bugsnag 404: resource not found" }
|
|
289
|
+
false
|
|
290
|
+
else
|
|
291
|
+
Superkick.logger.warn(log_tag) { "Bugsnag HTTP #{resp.status}" }
|
|
292
|
+
false
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def build_connection
|
|
297
|
+
token = self[:token] || ENV["BUGSNAG_TOKEN"]
|
|
298
|
+
|
|
299
|
+
Faraday.new(url: API_BASE) do |f|
|
|
300
|
+
f.request :authorization, "token", token if token
|
|
301
|
+
f.response :json
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
SUPERKICK [{{ "now" | time }}]: Bugsnag error — {{ error_class }}
|
|
2
|
+
{% if url -%}
|
|
3
|
+
URL: {{ url }}
|
|
4
|
+
{% endif -%}
|
|
5
|
+
Severity: {{ severity }} | Events: {{ events }}{% if users %} | Users: {{ users }}{% endif %}
|
|
6
|
+
First seen: {{ first_seen }} | Last seen: {{ last_seen }}
|
|
7
|
+
{% if release_stages.size > 0 -%}
|
|
8
|
+
Release stages: {{ release_stages | join: ", " }}
|
|
9
|
+
{% endif -%}
|
|
10
|
+
|
|
11
|
+
## Error
|
|
12
|
+
|
|
13
|
+
**{{ error_class }}**: {% if message == "" %}(no message){% else %}{{ message }}{% endif %}
|
|
14
|
+
|
|
15
|
+
Please investigate this error, identify the root cause, implement a fix, and
|
|
16
|
+
write tests to prevent regression. Check the Bugsnag dashboard for full stack
|
|
17
|
+
traces and contextual data.
|