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,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# NotifierStateStore — injectable key-value state storage for stateful notifiers.
|
|
5
|
+
#
|
|
6
|
+
# Stateful notifiers (Slack, Datadog, Honeybadger) track per-agent state across
|
|
7
|
+
# events (e.g. Slack thread_ts for threading, spawned_at for duration computation).
|
|
8
|
+
# This class provides an explicit, injectable interface for that state.
|
|
9
|
+
#
|
|
10
|
+
# The Memory subclass preserves current behavior (in-memory, mutex-protected,
|
|
11
|
+
# lost on server restart). The hosted Rails app provides a database-backed
|
|
12
|
+
# implementation for persistence and multi-process sharing.
|
|
13
|
+
#
|
|
14
|
+
# Interface:
|
|
15
|
+
# get(notifier_type, key) → Hash or nil
|
|
16
|
+
# put(notifier_type, key, data) → void
|
|
17
|
+
# delete(notifier_type, key) → void
|
|
18
|
+
class NotifierStateStore
|
|
19
|
+
def get(notifier_type, key)
|
|
20
|
+
raise NotImplementedError, "#{self.class}#get not implemented"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def put(notifier_type, key, data)
|
|
24
|
+
raise NotImplementedError, "#{self.class}#put not implemented"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def delete(notifier_type, key)
|
|
28
|
+
raise NotImplementedError, "#{self.class}#delete not implemented"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# In-memory implementation — thread-safe, process-local, lost on restart.
|
|
32
|
+
# Default for local server mode.
|
|
33
|
+
class Memory < NotifierStateStore
|
|
34
|
+
def initialize
|
|
35
|
+
@data = {}
|
|
36
|
+
@mutex = Mutex.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def get(notifier_type, key)
|
|
40
|
+
@mutex.synchronize { @data.dig(notifier_type, key)&.dup }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def put(notifier_type, key, data)
|
|
44
|
+
@mutex.synchronize do
|
|
45
|
+
@data[notifier_type] ||= {}
|
|
46
|
+
@data[notifier_type][key] = data
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def delete(notifier_type, key)
|
|
51
|
+
@mutex.synchronize { @data[notifier_type]&.delete(key) }
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "liquid"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
# NotifierTemplate — renders Liquid templates with structured output via
|
|
7
|
+
# block tags. Block tags accumulate data onto a mutable accumulator
|
|
8
|
+
# (stored in Liquid registers), producing both structured and text output.
|
|
9
|
+
#
|
|
10
|
+
# Usage:
|
|
11
|
+
#
|
|
12
|
+
# block_class = NotifierTemplate.create_block_class(->(ctx, text, attrs) {
|
|
13
|
+
# ctx.blocks << { type: "header", text: text }
|
|
14
|
+
# })
|
|
15
|
+
#
|
|
16
|
+
# result = NotifierTemplate.render(
|
|
17
|
+
# source: template_source,
|
|
18
|
+
# environment: liquid_environment,
|
|
19
|
+
# assigns: { "event_type" => "agent_completed" },
|
|
20
|
+
# accumulator: SlackContext.new
|
|
21
|
+
# )
|
|
22
|
+
# result[:structured] # => SlackContext with populated blocks
|
|
23
|
+
# result[:text] # => rendered text outside block tags
|
|
24
|
+
class NotifierTemplate
|
|
25
|
+
# Generate a Liquid::Block subclass that accumulates structured output.
|
|
26
|
+
# The handler receives (accumulator, rendered_body_text, attributes) and
|
|
27
|
+
# pushes structured data onto the accumulator. The block renders as ""
|
|
28
|
+
# in the template output — structured results are read from the
|
|
29
|
+
# accumulator after rendering.
|
|
30
|
+
#
|
|
31
|
+
# Attribute values are resolved as Liquid expressions at render time:
|
|
32
|
+
# - Quoted values (`key: "literal"`) stay as literal strings
|
|
33
|
+
# - Bare identifiers (`key: issue.url`) are resolved from the
|
|
34
|
+
# template context, matching the behavior of Liquid's built-in
|
|
35
|
+
# {% render %} and {% include %} tags
|
|
36
|
+
def self.create_block_class(handler)
|
|
37
|
+
Class.new(::Liquid::Block) do
|
|
38
|
+
define_method(:render) do |context|
|
|
39
|
+
text = super(context).strip
|
|
40
|
+
attrs = evaluate_attributes(context)
|
|
41
|
+
handler.call(context.registers[:notifier_accumulator], text, attrs)
|
|
42
|
+
""
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
define_method(:parse_raw_attributes) do
|
|
48
|
+
@_parsed_expressions ||= begin
|
|
49
|
+
markup = @markup.to_s.strip
|
|
50
|
+
return {} if markup.empty?
|
|
51
|
+
|
|
52
|
+
exprs = {}
|
|
53
|
+
markup.scan(::Liquid::TagAttributes) do |key, raw_value|
|
|
54
|
+
exprs[key] = ::Liquid::Expression.parse(raw_value)
|
|
55
|
+
end
|
|
56
|
+
exprs
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
define_method(:evaluate_attributes) do |context|
|
|
61
|
+
parse_raw_attributes.each_with_object({}) do |(key, expr), attrs|
|
|
62
|
+
value = context.evaluate(expr)
|
|
63
|
+
attrs[key] = value.to_s unless value.nil?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Generate a Liquid::Tag subclass (bodyless — no end tag) that
|
|
70
|
+
# accumulates structured output. The handler receives
|
|
71
|
+
# (accumulator, attributes) and pushes structured data onto the
|
|
72
|
+
# accumulator. The tag renders as "" in the template output.
|
|
73
|
+
#
|
|
74
|
+
# Attribute values are resolved as Liquid expressions at render time
|
|
75
|
+
# (see create_block_class for details).
|
|
76
|
+
def self.create_tag_class(handler)
|
|
77
|
+
Class.new(::Liquid::Tag) do
|
|
78
|
+
define_method(:render) do |context|
|
|
79
|
+
attrs = evaluate_attributes(context)
|
|
80
|
+
handler.call(context.registers[:notifier_accumulator], attrs)
|
|
81
|
+
""
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
define_method(:parse_raw_attributes) do
|
|
87
|
+
@_parsed_expressions ||= begin
|
|
88
|
+
markup = @markup.to_s.strip
|
|
89
|
+
return {} if markup.empty?
|
|
90
|
+
|
|
91
|
+
exprs = {}
|
|
92
|
+
markup.scan(::Liquid::TagAttributes) do |key, raw_value|
|
|
93
|
+
exprs[key] = ::Liquid::Expression.parse(raw_value)
|
|
94
|
+
end
|
|
95
|
+
exprs
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
define_method(:evaluate_attributes) do |context|
|
|
100
|
+
parse_raw_attributes.each_with_object({}) do |(key, expr), attrs|
|
|
101
|
+
value = context.evaluate(expr)
|
|
102
|
+
attrs[key] = value.to_s unless value.nil?
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Render a template, returning { structured:, text: }.
|
|
109
|
+
#
|
|
110
|
+
# @param source [String] Liquid template source
|
|
111
|
+
# @param environment [Liquid::Environment] pre-built environment with filters and tags
|
|
112
|
+
# @param assigns [Hash] template variables (string keys)
|
|
113
|
+
# @param accumulator [Object] mutable accumulator for block tags
|
|
114
|
+
# @return [Hash] { structured: accumulator, text: rendered_text }
|
|
115
|
+
def self.render(source:, environment:, assigns:, accumulator:)
|
|
116
|
+
template = ::Liquid::Template.parse(source, environment:)
|
|
117
|
+
text = template.render(assigns, registers: {notifier_accumulator: accumulator}).strip
|
|
118
|
+
{structured: accumulator, text:}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Notifiers
|
|
5
|
+
# Runs a shell command when an injection or lifecycle event occurs.
|
|
6
|
+
#
|
|
7
|
+
# The command string is executed via the system shell. Event details
|
|
8
|
+
# are passed as environment variables so commands can build rich
|
|
9
|
+
# notifications:
|
|
10
|
+
#
|
|
11
|
+
# SUPERKICK_EVENT_TYPE — e.g. "ci_failure", "pr_comment"
|
|
12
|
+
# SUPERKICK_MONITOR_TYPE — e.g. "github", "shell"
|
|
13
|
+
# SUPERKICK_MONITOR_NAME — e.g. "github", "disk_check"
|
|
14
|
+
# SUPERKICK_AGENT_ID — the agent that received the injection
|
|
15
|
+
# SUPERKICK_MESSAGE — a short human-readable summary
|
|
16
|
+
# SUPERKICK_BODY — rendered template text (empty if no template)
|
|
17
|
+
#
|
|
18
|
+
# The command string also supports $VARIABLE interpolation for
|
|
19
|
+
# inline use:
|
|
20
|
+
#
|
|
21
|
+
# notifications:
|
|
22
|
+
# - type: command
|
|
23
|
+
# run: "notify-send 'Superkick' '$SUPERKICK_MESSAGE'"
|
|
24
|
+
# - type: command
|
|
25
|
+
# run: "curl -X POST $SLACK_WEBHOOK -d '{\"text\": \"$SUPERKICK_BODY\"}'"
|
|
26
|
+
#
|
|
27
|
+
# Template support:
|
|
28
|
+
# Templates can define additional env vars using the {% env %} block tag:
|
|
29
|
+
#
|
|
30
|
+
# {% env key: "pr_title" %}{{ pull_request.title }}{% endenv %}
|
|
31
|
+
# {% env key: "repo" %}{{ repo }}{% endenv %}
|
|
32
|
+
# PR #{{ pull_request.number }} needs review.
|
|
33
|
+
#
|
|
34
|
+
# The {% env %} blocks populate SUPERKICK_PR_TITLE, SUPERKICK_REPO, etc.
|
|
35
|
+
# Text outside blocks becomes SUPERKICK_BODY. All names are uppercased
|
|
36
|
+
# and prefixed with SUPERKICK_ automatically.
|
|
37
|
+
#
|
|
38
|
+
# Templates are resolved from:
|
|
39
|
+
# 1. ~/.superkick/templates/notifications/command/<event_type>.liquid
|
|
40
|
+
# 2. ~/.superkick/templates/notifications/command/default.liquid
|
|
41
|
+
#
|
|
42
|
+
# Commands are killed after timeout_seconds (default 10) to prevent
|
|
43
|
+
# hung notification processes from accumulating.
|
|
44
|
+
class Command < Notifier
|
|
45
|
+
def self.type = :command
|
|
46
|
+
|
|
47
|
+
def self.setup_label = "Command"
|
|
48
|
+
|
|
49
|
+
def self.setup_config
|
|
50
|
+
<<~YAML
|
|
51
|
+
- type: command
|
|
52
|
+
run: "notify-send 'Superkick' '$SUPERKICK_MESSAGE'"
|
|
53
|
+
# timeout_seconds: 10 # kill command after this many seconds
|
|
54
|
+
# events: # restrict which events fire this notifier
|
|
55
|
+
# - agent_completed
|
|
56
|
+
# - agent_failed
|
|
57
|
+
# - agent_blocked
|
|
58
|
+
YAML
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
DEFAULT_TIMEOUT = 10
|
|
62
|
+
|
|
63
|
+
liquid do
|
|
64
|
+
context do
|
|
65
|
+
attribute :env_vars, default: -> { {} }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
block :env do |ctx, text, attrs|
|
|
69
|
+
name = attrs["key"]
|
|
70
|
+
ctx.env_vars[name] = text if name
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize(run: nil, timeout_seconds: DEFAULT_TIMEOUT, **opts)
|
|
75
|
+
super(**opts)
|
|
76
|
+
@run = run
|
|
77
|
+
@timeout_seconds = timeout_seconds.to_i
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def notify(payload)
|
|
81
|
+
unless @run
|
|
82
|
+
Superkick.logger.warn("notifier:command") { "No 'run' command configured — skipping" }
|
|
83
|
+
return
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
env = build_env(payload)
|
|
87
|
+
result = ProcessRunner.spawn(@run, timeout: @timeout_seconds, env:)
|
|
88
|
+
|
|
89
|
+
if result[:timed_out]
|
|
90
|
+
Superkick.logger.warn("notifier:command") { "Command timed out after #{@timeout_seconds}s: #{@run}" }
|
|
91
|
+
elsif result[:status] && !result[:status].success?
|
|
92
|
+
Superkick.logger.warn("notifier:command") { "Command exited #{result[:status].exitstatus}" }
|
|
93
|
+
end
|
|
94
|
+
rescue Errno::ENOENT => e
|
|
95
|
+
Superkick.logger.warn("notifier:command") { "Command not found: #{e.message}" }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def build_env(payload)
|
|
101
|
+
monitor = payload[:monitor]
|
|
102
|
+
env = {
|
|
103
|
+
"SUPERKICK_EVENT_TYPE" => payload[:event_type].to_s,
|
|
104
|
+
"SUPERKICK_MONITOR_TYPE" => monitor&.type.to_s,
|
|
105
|
+
"SUPERKICK_MONITOR_NAME" => monitor&.name.to_s,
|
|
106
|
+
"SUPERKICK_AGENT_ID" => payload[:agent_id].to_s,
|
|
107
|
+
"SUPERKICK_MESSAGE" => payload[:message].to_s
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
template_result = render_notification(payload)
|
|
111
|
+
if template_result
|
|
112
|
+
env["SUPERKICK_BODY"] = template_result[:text]
|
|
113
|
+
template_result[:structured].env_vars.each do |name, value|
|
|
114
|
+
env["SUPERKICK_#{name.upcase}"] = value
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
env
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
Notifier.register(Notifiers::Command)
|
|
124
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Notifiers
|
|
5
|
+
# Writes a BEL character (\a) to the server's original TTY.
|
|
6
|
+
#
|
|
7
|
+
# This is the zero-config default notifier. Most terminal emulators
|
|
8
|
+
# respond to BEL with a visual or audible alert:
|
|
9
|
+
# - iTerm2 bounces the dock icon
|
|
10
|
+
# - tmux marks the window with activity
|
|
11
|
+
# - GNOME Terminal flashes the tab title
|
|
12
|
+
# - Windows Terminal flashes the taskbar
|
|
13
|
+
#
|
|
14
|
+
# Config (config.yml):
|
|
15
|
+
# notifications:
|
|
16
|
+
# - type: terminal_bell
|
|
17
|
+
class TerminalBell < Notifier
|
|
18
|
+
def self.type = :terminal_bell
|
|
19
|
+
|
|
20
|
+
def self.setup_label = "Terminal Bell"
|
|
21
|
+
|
|
22
|
+
def self.setup_config
|
|
23
|
+
<<~YAML
|
|
24
|
+
- type: terminal_bell
|
|
25
|
+
YAML
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def notify(payload)
|
|
29
|
+
# Write BEL to stderr — the server's stderr is typically
|
|
30
|
+
# connected to the controlling terminal (unless daemonized,
|
|
31
|
+
# in which case it's redirected to the log file and the bell
|
|
32
|
+
# is a harmless no-op).
|
|
33
|
+
$stderr.write("\a")
|
|
34
|
+
rescue IOError
|
|
35
|
+
# stderr closed or redirected — nothing to do.
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
Notifier.register(Notifiers::TerminalBell)
|
|
41
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Manages writing PTY output to a per-agent log file.
|
|
5
|
+
# Raw bytes (including ANSI escape codes) are written in append mode
|
|
6
|
+
# with sync flushing so `tail -f` works in real time.
|
|
7
|
+
#
|
|
8
|
+
# Simple size-based rotation: one rotated file (*.1). When the log
|
|
9
|
+
# exceeds max_size, the current file becomes .1 and a new file starts.
|
|
10
|
+
class OutputLogger
|
|
11
|
+
MAX_SIZE = 10 * 1024 * 1024 # 10 MB default
|
|
12
|
+
|
|
13
|
+
def initialize(agent_id:, max_size: MAX_SIZE, log_dir: Superkick.config.output_logs_dir)
|
|
14
|
+
@path = File.join(log_dir, "#{agent_id}.log")
|
|
15
|
+
@max_size = max_size
|
|
16
|
+
@file = nil
|
|
17
|
+
@bytes_written = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def start
|
|
21
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
22
|
+
@file = File.open(@path, "ab")
|
|
23
|
+
@file.sync = true
|
|
24
|
+
@bytes_written = @file.size
|
|
25
|
+
self
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def write(data)
|
|
29
|
+
return unless @file
|
|
30
|
+
rotate! if @bytes_written + data.bytesize > @max_size
|
|
31
|
+
@file.write(data)
|
|
32
|
+
@bytes_written += data.bytesize
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def close
|
|
36
|
+
@file&.close
|
|
37
|
+
@file = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_reader :path
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def rotate!
|
|
45
|
+
@file.close
|
|
46
|
+
rotated = "#{@path}.1"
|
|
47
|
+
FileUtils.rm_f(rotated)
|
|
48
|
+
File.rename(@path, rotated)
|
|
49
|
+
@file = File.open(@path, "ab")
|
|
50
|
+
@file.sync = true
|
|
51
|
+
@bytes_written = 0
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "liquid"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
# Poller — abstract base class for all polling event sources.
|
|
7
|
+
#
|
|
8
|
+
# Provides the shared run loop, error handling, backoff, and config
|
|
9
|
+
# validation used by both Monitor (agent-bound) and Spawner (server-level).
|
|
10
|
+
#
|
|
11
|
+
# Subclass contract:
|
|
12
|
+
# self.type → unique Symbol
|
|
13
|
+
# self.description → human-readable summary (optional)
|
|
14
|
+
# self.required_config → Array of Symbol keys validated before start
|
|
15
|
+
# self.templates_dir → path to Liquid templates (optional)
|
|
16
|
+
# liquid do ... end → declare Liquid filters, blocks, context (optional)
|
|
17
|
+
# tick → called each interval; use dispatch(event) to emit
|
|
18
|
+
# on_start → optional hook called once before the run loop
|
|
19
|
+
class Poller
|
|
20
|
+
include Superkick::Liquid
|
|
21
|
+
|
|
22
|
+
# Raised inside tick to signal API rate-limit; triggers backoff.
|
|
23
|
+
class RateLimited < StandardError; end
|
|
24
|
+
|
|
25
|
+
# Raised inside tick to signal a permanent configuration error; exits thread.
|
|
26
|
+
class FatalError < StandardError; end
|
|
27
|
+
|
|
28
|
+
attr_reader :name
|
|
29
|
+
|
|
30
|
+
def initialize(name:, config:, handler:)
|
|
31
|
+
@name = name.to_s
|
|
32
|
+
@config = config
|
|
33
|
+
@handler = handler
|
|
34
|
+
@running = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Config accessor — always use symbol keys.
|
|
38
|
+
def [](key)
|
|
39
|
+
@config[key.to_sym]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Start the blocking run loop (call from a dedicated thread).
|
|
43
|
+
def run
|
|
44
|
+
begin
|
|
45
|
+
validate_config!
|
|
46
|
+
on_start
|
|
47
|
+
rescue FatalError => e
|
|
48
|
+
Superkick.logger.error(log_tag) { "Fatal startup error: #{e.message}" }
|
|
49
|
+
return
|
|
50
|
+
end
|
|
51
|
+
@running = true
|
|
52
|
+
|
|
53
|
+
loop do
|
|
54
|
+
flush_pending_events
|
|
55
|
+
begin
|
|
56
|
+
tick
|
|
57
|
+
rescue RateLimited => e
|
|
58
|
+
Superkick.logger.warn(log_tag) { "Rate limited: #{e.message}; backing off #{Superkick.config.rate_limit_backoff}s" }
|
|
59
|
+
sleep(Superkick.config.rate_limit_backoff)
|
|
60
|
+
rescue FatalError => e
|
|
61
|
+
Superkick.logger.error(log_tag) { "Fatal error: #{e.message}; stopping" }
|
|
62
|
+
break
|
|
63
|
+
rescue => e
|
|
64
|
+
Superkick.logger.error(log_tag) { "Unexpected error: #{e.message}\n#{e.backtrace.first(3).join("\n")}" }
|
|
65
|
+
sleep(Superkick.config.error_backoff)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sleep(Superkick.config.poll_interval)
|
|
69
|
+
end
|
|
70
|
+
ensure
|
|
71
|
+
@running = false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def stop
|
|
75
|
+
@running = false
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Called once before the run loop starts.
|
|
79
|
+
def on_start
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Subclasses must implement.
|
|
83
|
+
def tick
|
|
84
|
+
raise NotImplementedError, "#{self.class}#tick not implemented"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def log_tag
|
|
88
|
+
"#{self.class.type}(#{@name})"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Class-level interface that subclasses must implement:
|
|
92
|
+
# self.type → Symbol
|
|
93
|
+
# self.required_config → Array of Symbol keys
|
|
94
|
+
# self.templates_dir → path or nil
|
|
95
|
+
# self.description → String or nil
|
|
96
|
+
|
|
97
|
+
class << self
|
|
98
|
+
def type
|
|
99
|
+
raise NotImplementedError, "#{self}.type not defined"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def description = nil
|
|
103
|
+
def required_config = []
|
|
104
|
+
def templates_dir = nil
|
|
105
|
+
def setup_label = nil
|
|
106
|
+
def setup_config = nil
|
|
107
|
+
|
|
108
|
+
def event_types
|
|
109
|
+
dir = templates_dir
|
|
110
|
+
return [] unless dir && File.directory?(dir)
|
|
111
|
+
Dir.glob(File.join(dir, "*.liquid")).map { |f| File.basename(f, ".liquid") }.sort
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def flush_pending_events
|
|
118
|
+
@handler.flush_pending
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_config!
|
|
122
|
+
missing = self.class.required_config.reject { |k| @config.key?(k.to_sym) }
|
|
123
|
+
raise FatalError, "Missing required config keys: #{missing.join(", ")}" unless missing.empty?
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Unified subprocess execution with reliable timeouts.
|
|
5
|
+
#
|
|
6
|
+
# Ruby's Timeout.timeout uses Thread.raise to deliver async exceptions,
|
|
7
|
+
# which cannot reliably interrupt C-level blocking calls like waitpid
|
|
8
|
+
# or IO#read. This module avoids Timeout entirely:
|
|
9
|
+
#
|
|
10
|
+
# .run — captures stdout+stderr, returns {output:, status:, timed_out:}
|
|
11
|
+
# .spawn — fire-and-forget, no output capture, kills on timeout
|
|
12
|
+
#
|
|
13
|
+
# Both methods SIGTERM the child on timeout and reap it to prevent
|
|
14
|
+
# zombies. All timeouts use CLOCK_MONOTONIC to avoid wall-clock skew.
|
|
15
|
+
module ProcessRunner
|
|
16
|
+
# Run a command, capture combined stdout+stderr, and return the result.
|
|
17
|
+
#
|
|
18
|
+
# @param cmd [String] shell command to execute
|
|
19
|
+
# @param timeout [Integer] seconds before SIGTERM (default 30)
|
|
20
|
+
# @param env [Hash] environment variables (default {})
|
|
21
|
+
# @param chdir [String, nil] working directory (default nil)
|
|
22
|
+
# @return [Hash] { output: String, status: Process::Status|nil, timed_out: Boolean }
|
|
23
|
+
def self.run(cmd, timeout: 30, env: {}, chdir: nil)
|
|
24
|
+
options = {err: [:child, :out]}
|
|
25
|
+
options[:chdir] = chdir if chdir
|
|
26
|
+
|
|
27
|
+
output = +""
|
|
28
|
+
timed_out = false
|
|
29
|
+
|
|
30
|
+
IO.popen(env, cmd, **options) do |io|
|
|
31
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
32
|
+
|
|
33
|
+
loop do
|
|
34
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
35
|
+
if remaining <= 0
|
|
36
|
+
Process.kill("TERM", io.pid)
|
|
37
|
+
timed_out = true
|
|
38
|
+
break
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
ready = IO.select([io], nil, nil, [remaining, 0.5].min)
|
|
42
|
+
if ready
|
|
43
|
+
chunk = io.read_nonblock(8192, exception: false)
|
|
44
|
+
break if chunk == :wait_readable
|
|
45
|
+
break if chunk.nil? # EOF
|
|
46
|
+
output << chunk
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
io.close
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
{output:, status: $?, timed_out:}
|
|
54
|
+
rescue SystemCallError, IOError => e
|
|
55
|
+
{output: e.message, status: nil, timed_out: false}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Spawn a command without capturing output. Blocks until the
|
|
59
|
+
# process exits or the timeout is reached, then returns.
|
|
60
|
+
#
|
|
61
|
+
# @param cmd [String] shell command to execute
|
|
62
|
+
# @param timeout [Integer] seconds before SIGTERM (default 10)
|
|
63
|
+
# @param env [Hash] environment variables (default {})
|
|
64
|
+
# @return [Hash] { status: Process::Status|nil, timed_out: Boolean }
|
|
65
|
+
def self.spawn(cmd, timeout: 10, env: {})
|
|
66
|
+
pid = Process.spawn(env, cmd, %i[out err] => File::NULL)
|
|
67
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
68
|
+
|
|
69
|
+
loop do
|
|
70
|
+
_pid, status = Process.waitpid2(pid, Process::WNOHANG)
|
|
71
|
+
return {status:, timed_out: false} if status
|
|
72
|
+
|
|
73
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
74
|
+
Process.kill("TERM", pid)
|
|
75
|
+
_, status = Process.waitpid2(pid)
|
|
76
|
+
return {status:, timed_out: true}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
sleep 0.1
|
|
80
|
+
end
|
|
81
|
+
rescue Errno::ENOENT => e
|
|
82
|
+
raise e # let caller handle command-not-found
|
|
83
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
84
|
+
{status: nil, timed_out: false}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|