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,311 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Spawn
|
|
7
|
+
# AgentSpawner — manages repository acquisition and agent subprocess lifecycle
|
|
8
|
+
# for spawned agents. Delegates process management to an Agent::Runtime.
|
|
9
|
+
class AgentSpawner
|
|
10
|
+
attr_writer :supervisor
|
|
11
|
+
|
|
12
|
+
def initialize(store:, notification_dispatcher:, config: Superkick.config, runtime: Agent::Runtime::Local.new,
|
|
13
|
+
spawn_injector: nil, supervisor: nil)
|
|
14
|
+
@store = store
|
|
15
|
+
@config = config
|
|
16
|
+
@runtime = runtime
|
|
17
|
+
@spawn_injector = spawn_injector || Spawn::Injector.new(store:)
|
|
18
|
+
@supervisor = supervisor
|
|
19
|
+
@notification_dispatcher = notification_dispatcher
|
|
20
|
+
@runtime_handles = {}
|
|
21
|
+
@vcs_state = {} # agent_id => { adapter:, destination: }
|
|
22
|
+
@cooldowns = {}
|
|
23
|
+
@mutex = Mutex.new
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Spawn a new agent.
|
|
27
|
+
# @return [Hash] { status:, agent_id:, handle: }
|
|
28
|
+
def spawn(event:, spawner_config:)
|
|
29
|
+
max = spawner_config[:max_concurrent]
|
|
30
|
+
if max && active_count >= max
|
|
31
|
+
return {status: :at_capacity}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
spawner_name = spawner_config[:name]
|
|
35
|
+
if spawner_name && cooldown_active?(spawner_name, spawner_config)
|
|
36
|
+
Superkick.logger.info("spawner") { "Cooldown active for #{spawner_name} — skipping" }
|
|
37
|
+
return {status: :cooldown}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
agent_id = generate_agent_id(event, spawner_config)
|
|
41
|
+
|
|
42
|
+
# Workflow VCS inheritance: skip setup if inherited VCS state is present
|
|
43
|
+
if event[:inherited_vcs_state]
|
|
44
|
+
@mutex.synchronize { @vcs_state[agent_id] = event[:inherited_vcs_state] }
|
|
45
|
+
working_dir = event[:working_dir] || Dir.pwd
|
|
46
|
+
else
|
|
47
|
+
working_dir = run_setup(event, spawner_config)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
handle = start_proxy(agent_id:, working_dir:,
|
|
51
|
+
spawner_config:)
|
|
52
|
+
|
|
53
|
+
@mutex.synchronize { @runtime_handles[agent_id] = handle }
|
|
54
|
+
|
|
55
|
+
wait_for_registration(agent_id)
|
|
56
|
+
|
|
57
|
+
# Stamp spawn_info on the agent
|
|
58
|
+
agent = @store.get(agent_id)
|
|
59
|
+
if agent
|
|
60
|
+
info = {
|
|
61
|
+
spawner_name:,
|
|
62
|
+
event_type: event[:event_type].to_s,
|
|
63
|
+
spawned_at: Time.now.iso8601
|
|
64
|
+
}
|
|
65
|
+
# Workflow metadata
|
|
66
|
+
info[:workflow_depth] = event[:workflow_depth] if event[:workflow_depth]
|
|
67
|
+
info[:workflow_iterations] = event[:workflow_iterations] if event[:workflow_iterations]
|
|
68
|
+
info[:parent_agent_id] = event[:parent_agent_id] if event[:parent_agent_id]
|
|
69
|
+
info[:workflow_source] = spawner_config[:workflow_source] if spawner_config[:workflow_source]
|
|
70
|
+
info[:working_dir] = working_dir
|
|
71
|
+
info[:context] = extract_context(event)
|
|
72
|
+
agent.spawn_info = info
|
|
73
|
+
agent.set_working_dir(working_dir)
|
|
74
|
+
|
|
75
|
+
# Agent metadata from event
|
|
76
|
+
agent.set_team(team_id: event[:team_id], team_role: event[:team_role]) if event[:team_id]
|
|
77
|
+
agent.set_role(event[:role]) if event[:role]
|
|
78
|
+
|
|
79
|
+
# Auto-attach monitors from spawner event (includes team_log when present)
|
|
80
|
+
attach_spawn_monitors(agent, event)
|
|
81
|
+
|
|
82
|
+
# Auto-attach per-agent notifiers from spawner event
|
|
83
|
+
attach_spawn_notifiers(agent, event)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@spawn_injector.inject(agent_id:, event:,
|
|
87
|
+
spawner_config:)
|
|
88
|
+
|
|
89
|
+
# Record cooldown timestamp
|
|
90
|
+
@mutex.synchronize { @cooldowns[spawner_name] = Process.clock_gettime(Process::CLOCK_MONOTONIC) } if spawner_name
|
|
91
|
+
|
|
92
|
+
# Start goal checker — defaults to agent_reported when no goal is configured
|
|
93
|
+
goal_config = spawner_config[:goal] || {type: :agent_signal}
|
|
94
|
+
if @supervisor
|
|
95
|
+
enriched = goal_config.merge(working_dir:)
|
|
96
|
+
# Wire team_id into goal config so the group goal can find its workers
|
|
97
|
+
enriched = enriched.merge(team_id: event[:team_id]) if event[:team_id]
|
|
98
|
+
# Inject rehydrated event context so goals can access Drops directly
|
|
99
|
+
# (e.g. self[:issue].number for GitHubIssueResolvedGoal)
|
|
100
|
+
context = extract_context(event)
|
|
101
|
+
rehydrated = Superkick::Drop.rehydrate(context)
|
|
102
|
+
rehydrated.each { |k, v| enriched[k] = v unless enriched.key?(k) }
|
|
103
|
+
@supervisor.start_goal_checker(agent_id:, goal_config: enriched, spawner_config:)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Start cost poller if any budget is configured
|
|
107
|
+
if spawner_config[:budget]&.any? && @supervisor
|
|
108
|
+
@supervisor.start_cost_poller(agent_id:, spawner_config:)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
dispatch_notification(
|
|
112
|
+
event: {event_type: :agent_spawned, monitor_type: :spawner, monitor_name: spawner_name || :system},
|
|
113
|
+
agent_id:,
|
|
114
|
+
message: "Agent #{agent_id} spawned by #{spawner_name || "system"}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
# Fire team_created when a team lead is spawned
|
|
118
|
+
if event[:team_id] && event[:team_role] == :lead
|
|
119
|
+
dispatch_notification(
|
|
120
|
+
event: {event_type: :team_created, monitor_type: :spawner, monitor_name: spawner_name || :system,
|
|
121
|
+
team_id: event[:team_id], team_members: [agent_id]},
|
|
122
|
+
agent_id:,
|
|
123
|
+
message: "Team #{event[:team_id]} created with lead #{agent_id}"
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
{status: :spawned, agent_id:, handle:}
|
|
128
|
+
rescue => e
|
|
129
|
+
Superkick.logger.error("spawner") { "Spawn failed: #{e.message}\n#{e.backtrace.first(5).join("\n")}" }
|
|
130
|
+
{status: :error, error: e.message}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Clean up when a spawned agent ends.
|
|
134
|
+
def agent_ended(agent_id)
|
|
135
|
+
vcs = @mutex.synchronize { @vcs_state.delete(agent_id) }
|
|
136
|
+
vcs[:adapter].teardown(destination: vcs[:destination]) if vcs
|
|
137
|
+
|
|
138
|
+
@mutex.synchronize { @runtime_handles.delete(agent_id) }
|
|
139
|
+
rescue => e
|
|
140
|
+
Superkick.logger.warn("agent_spawner") { "Cleanup failed for #{agent_id}: #{e.message}" }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Terminate a spawned agent gracefully.
|
|
144
|
+
def terminate(agent_id)
|
|
145
|
+
handle = @mutex.synchronize { @runtime_handles[agent_id] }
|
|
146
|
+
return unless handle
|
|
147
|
+
|
|
148
|
+
@runtime.terminate(handle:)
|
|
149
|
+
rescue Errno::ESRCH
|
|
150
|
+
agent_ended(agent_id)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def active_count
|
|
154
|
+
@mutex.synchronize { @runtime_handles.size }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def runtime_handles
|
|
158
|
+
@mutex.synchronize { @runtime_handles.dup }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def register_handle(agent_id, handle)
|
|
162
|
+
@mutex.synchronize { @runtime_handles[agent_id] = handle }
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Record a cooldown timestamp for a spawner. Used by tests to simulate
|
|
166
|
+
# recent spawn activity without going through the full spawn flow.
|
|
167
|
+
def record_cooldown(spawner_name:, at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
168
|
+
@mutex.synchronize { @cooldowns[spawner_name] = at }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Remove and return the VCS state for an agent.
|
|
172
|
+
# Used by Spawn::WorkflowExecutor for VCS ownership transfer.
|
|
173
|
+
def take_vcs_state(agent_id)
|
|
174
|
+
@mutex.synchronize { @vcs_state.delete(agent_id) }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def generate_agent_id(event, config)
|
|
178
|
+
if event[:agent_id]
|
|
179
|
+
event[:agent_id]
|
|
180
|
+
else
|
|
181
|
+
base = config[:name] || "spawned"
|
|
182
|
+
"#{base}-#{Process.pid}-#{SecureRandom.hex(4)}"
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def run_setup(event, config)
|
|
187
|
+
return Dir.pwd unless config[:repository]
|
|
188
|
+
|
|
189
|
+
agent_id = generate_agent_id(event, config)
|
|
190
|
+
acquire_repository(event, config, agent_id)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
private
|
|
194
|
+
|
|
195
|
+
def attach_spawn_monitors(agent, event)
|
|
196
|
+
monitors = event[:_spawn_monitors]
|
|
197
|
+
return unless monitors.is_a?(Hash) && monitors.any?
|
|
198
|
+
|
|
199
|
+
monitors.each do |name, monitor_config|
|
|
200
|
+
agent.set_monitor_config(name.to_sym, monitor_config)
|
|
201
|
+
@supervisor&.enqueue(:add_monitor, agent_id: agent.id, monitor_name: name.to_sym)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def attach_spawn_notifiers(agent, event)
|
|
206
|
+
notifiers = event[:_spawn_notifiers]
|
|
207
|
+
return unless notifiers.is_a?(Hash) && notifiers.any?
|
|
208
|
+
|
|
209
|
+
notifiers.each do |name, notifier_config|
|
|
210
|
+
agent.set_notifier_config(name.to_sym, notifier_config)
|
|
211
|
+
end
|
|
212
|
+
@notification_dispatcher.register_agent_notifiers(agent.id) if @notification_dispatcher.respond_to?(:register_agent_notifiers)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def start_proxy(agent_id:, working_dir:, spawner_config:)
|
|
216
|
+
driver_config = resolve_driver_config(spawner_config[:driver])
|
|
217
|
+
exe = File.expand_path($PROGRAM_NAME)
|
|
218
|
+
|
|
219
|
+
command = [exe, "agent", "--agent-id", agent_id, "--headless"]
|
|
220
|
+
|
|
221
|
+
if driver_config
|
|
222
|
+
command += ["--driver", driver_config[:type].to_s] if driver_config[:type]
|
|
223
|
+
command += ["--driver-config-dir", File.expand_path(driver_config[:config_dir])] if driver_config[:config_dir]
|
|
224
|
+
command += ["--driver-command", driver_config[:command]] if driver_config[:command]
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
command += ["--"] + driver_config[:args] if driver_config&.dig(:args)&.any?
|
|
228
|
+
|
|
229
|
+
spawn_env = driver_config&.dig(:env) || {}
|
|
230
|
+
spawn_env["SUPERKICK_AGENT_ID"] = agent_id
|
|
231
|
+
|
|
232
|
+
@runtime.provision(
|
|
233
|
+
agent_id:,
|
|
234
|
+
config: {env: spawn_env, command:, working_dir:}
|
|
235
|
+
)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def resolve_driver_config(driver)
|
|
239
|
+
Driver.normalize_driver(driver, profile_source: @config.driver_profile_source)
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def acquire_repository(event, config, agent_id)
|
|
243
|
+
repository_name = config[:repository].to_sym
|
|
244
|
+
repository = @config.repository_source.find_by_name(repository_name)
|
|
245
|
+
raise Poller::FatalError, "Unknown repository: #{repository_name}" unless repository
|
|
246
|
+
|
|
247
|
+
adapter = VersionControl.for_repository(repository)
|
|
248
|
+
branch = interpolate_branch(config[:branch_template] || "superkick-{{ agent_id }}", event)
|
|
249
|
+
base_branch = config[:base_branch] || "main"
|
|
250
|
+
destination = File.join(@config.workspaces_dir, agent_id)
|
|
251
|
+
|
|
252
|
+
adapter.acquire(source: repository, destination:, branch:, base_branch:)
|
|
253
|
+
@mutex.synchronize { @vcs_state[agent_id] = {adapter:, destination:} }
|
|
254
|
+
destination
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Keys that are internal spawn/workflow metadata, not integration context.
|
|
258
|
+
INTERNAL_EVENT_KEYS = %i[
|
|
259
|
+
event_type monitor_type monitor_name agent_id
|
|
260
|
+
team_id team_role role
|
|
261
|
+
inherited_pipeline inherited_vcs_state working_dir
|
|
262
|
+
parent_agent_id parent_goal_status parent_goal_summary parent_spawner_name
|
|
263
|
+
workflow_depth workflow_iterations
|
|
264
|
+
_spawn_monitors _spawn_notifiers
|
|
265
|
+
].freeze
|
|
266
|
+
|
|
267
|
+
def extract_context(event)
|
|
268
|
+
event.except(*INTERNAL_EVENT_KEYS)
|
|
269
|
+
.transform_values { Superkick::Drop.serialize(it) }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def interpolate_branch(template, event)
|
|
273
|
+
context = extract_context(event)
|
|
274
|
+
rehydrated = Superkick::Drop.rehydrate(context)
|
|
275
|
+
assigns = rehydrated.transform_keys(&:to_s)
|
|
276
|
+
assigns["agent_id"] = event[:agent_id].to_s if event[:agent_id]
|
|
277
|
+
|
|
278
|
+
liquid_template = ::Liquid::Template.parse(template)
|
|
279
|
+
liquid_template.render(assigns)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def cooldown_active?(spawner_name, config)
|
|
283
|
+
cooldown = config[:cooldown]
|
|
284
|
+
return false unless cooldown
|
|
285
|
+
|
|
286
|
+
last_spawn = @mutex.synchronize { @cooldowns[spawner_name] }
|
|
287
|
+
return false unless last_spawn
|
|
288
|
+
|
|
289
|
+
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - last_spawn
|
|
290
|
+
elapsed < cooldown
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def wait_for_registration(agent_id, timeout: 30)
|
|
294
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
295
|
+
loop do
|
|
296
|
+
return if @store.has?(agent_id)
|
|
297
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
298
|
+
raise "Agent #{agent_id} did not register within #{timeout}s"
|
|
299
|
+
end
|
|
300
|
+
sleep 0.5
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def dispatch_notification(...)
|
|
305
|
+
@notification_dispatcher.dispatch(...)
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Backwards-compatible alias
|
|
311
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Spawn
|
|
7
|
+
# Thread-safe store for pending spawn approvals and rejection records.
|
|
8
|
+
#
|
|
9
|
+
# When a spawner has `approval_required: true`, the Spawn::Handler stores
|
|
10
|
+
# the event here instead of spawning immediately. The user approves via
|
|
11
|
+
# `superkick approve <id>` (or rejects via `superkick reject <id>`), which
|
|
12
|
+
# triggers or discards the spawn.
|
|
13
|
+
#
|
|
14
|
+
# Rejected agent IDs are remembered so the spawner doesn't immediately
|
|
15
|
+
# re-dispatch the same event. Rejections can be cleared individually
|
|
16
|
+
# via `superkick approve --clear <id>` or all at once via
|
|
17
|
+
# `superkick approve --clear-all`.
|
|
18
|
+
class ApprovalStore
|
|
19
|
+
attr_reader :pending
|
|
20
|
+
|
|
21
|
+
def initialize
|
|
22
|
+
@pending = {}
|
|
23
|
+
@rejected = {}
|
|
24
|
+
@mutex = Mutex.new
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Add a pending approval. Returns the approval ID.
|
|
28
|
+
# Skips if the agent_id has been rejected.
|
|
29
|
+
def add(event:, spawner_config:)
|
|
30
|
+
id = generate_id(event)
|
|
31
|
+
@mutex.synchronize do
|
|
32
|
+
return nil if @rejected[id]
|
|
33
|
+
|
|
34
|
+
@pending[id] = {
|
|
35
|
+
id:,
|
|
36
|
+
event:,
|
|
37
|
+
spawner_config:,
|
|
38
|
+
created_at: Time.now.iso8601,
|
|
39
|
+
agent_id: event[:agent_id]
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
id
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Remove and return a pending approval by ID.
|
|
46
|
+
def take(id)
|
|
47
|
+
@mutex.synchronize { @pending.delete(id) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Get a pending approval without removing it.
|
|
51
|
+
def get(id)
|
|
52
|
+
@mutex.synchronize { @pending[id] }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# List all pending approvals.
|
|
56
|
+
def list
|
|
57
|
+
@mutex.synchronize { @pending.values.dup }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Check if a pending or rejected entry exists for a given agent_id.
|
|
61
|
+
def has_agent?(agent_id)
|
|
62
|
+
@mutex.synchronize do
|
|
63
|
+
@rejected.key?(agent_id) ||
|
|
64
|
+
@pending.any? { |_, v| v[:agent_id] == agent_id }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Record a rejection with an optional reason.
|
|
69
|
+
def reject(id, reason: nil)
|
|
70
|
+
entry = @mutex.synchronize { @pending.delete(id) }
|
|
71
|
+
return nil unless entry
|
|
72
|
+
|
|
73
|
+
@mutex.synchronize do
|
|
74
|
+
@rejected[id] = {
|
|
75
|
+
id:,
|
|
76
|
+
agent_id: entry[:agent_id],
|
|
77
|
+
rejected_at: Time.now.iso8601,
|
|
78
|
+
reason:
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
entry
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if an ID has been rejected.
|
|
85
|
+
def rejected?(id)
|
|
86
|
+
@mutex.synchronize { @rejected.key?(id) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# List all rejections.
|
|
90
|
+
def rejections
|
|
91
|
+
@mutex.synchronize { @rejected.values.dup }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Clear a specific rejection, allowing the event to be re-dispatched.
|
|
95
|
+
def clear_rejection(id)
|
|
96
|
+
@mutex.synchronize { @rejected.delete(id) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Clear all rejections.
|
|
100
|
+
def clear_all_rejections
|
|
101
|
+
@mutex.synchronize { @rejected.clear }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def generate_id(event)
|
|
107
|
+
event[:agent_id] || "approval-#{SecureRandom.hex(4)}"
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Backwards-compatible alias
|
|
113
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Spawn
|
|
5
|
+
# Handler — event handler for spawners.
|
|
6
|
+
#
|
|
7
|
+
# Receives events from spawners, checks dedup via AgentStore, and delegates
|
|
8
|
+
# to Spawn::AgentSpawner. This is the spawner equivalent of InjectHandler.
|
|
9
|
+
#
|
|
10
|
+
# When `approval_required: true` is set on the spawner config, events are
|
|
11
|
+
# held in the Spawn::ApprovalStore and a `agent_pending_approval` notification is
|
|
12
|
+
# fired instead of spawning immediately. The user approves via
|
|
13
|
+
# `superkick approve <id>`.
|
|
14
|
+
class Handler
|
|
15
|
+
attr_reader :result
|
|
16
|
+
|
|
17
|
+
def initialize(spawner:, store:, spawner_config:, notification_dispatcher:, approval_store: nil,
|
|
18
|
+
repository_source: nil)
|
|
19
|
+
@spawner = spawner
|
|
20
|
+
@store = store
|
|
21
|
+
@spawner_config = spawner_config
|
|
22
|
+
@approval_store = approval_store
|
|
23
|
+
@notification_dispatcher = notification_dispatcher
|
|
24
|
+
@repository_source = repository_source
|
|
25
|
+
@pending = []
|
|
26
|
+
@pending_mutex = Mutex.new
|
|
27
|
+
@result = nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Handle a dispatched event.
|
|
31
|
+
# @return [Symbol] :spawned, :duplicate, :at_capacity, :cooldown, :pending_approval, :error
|
|
32
|
+
def handle(event:)
|
|
33
|
+
agent_id = event[:agent_id]
|
|
34
|
+
|
|
35
|
+
if agent_id && @store.has?(agent_id)
|
|
36
|
+
Superkick.logger.info("spawn_handler") { "Agent #{agent_id} already exists — skipping" }
|
|
37
|
+
return :duplicate
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Check if approval is required
|
|
41
|
+
if @spawner_config[:approval_required] && @approval_store
|
|
42
|
+
if agent_id && @approval_store.has_agent?(agent_id)
|
|
43
|
+
Superkick.logger.info("spawn_handler") { "Approval already pending for #{agent_id} — skipping" }
|
|
44
|
+
return :pending_approval
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
approval_id = @approval_store.add(event:, spawner_config: @spawner_config)
|
|
48
|
+
spawner_name = @spawner_config[:name]
|
|
49
|
+
Superkick.logger.info("spawn_handler") { "Approval required for #{agent_id} — queued as #{approval_id}" }
|
|
50
|
+
|
|
51
|
+
dispatch_notification(
|
|
52
|
+
event: {event_type: :agent_pending_approval, monitor_type: :spawner, monitor_name: spawner_name || :system,
|
|
53
|
+
approval_id:},
|
|
54
|
+
agent_id: agent_id || approval_id,
|
|
55
|
+
message: "Agent #{agent_id || approval_id} pending approval from #{spawner_name || "spawner"}"
|
|
56
|
+
)
|
|
57
|
+
return :pending_approval
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
spawn_event(event)
|
|
61
|
+
rescue => e
|
|
62
|
+
Superkick.logger.error("spawn_handler") { "Spawn error: #{e.message}" }
|
|
63
|
+
:error
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Retry any pending events (e.g. those that were at capacity).
|
|
67
|
+
def flush_pending
|
|
68
|
+
events = @pending_mutex.synchronize do
|
|
69
|
+
evts = @pending.dup
|
|
70
|
+
@pending.clear
|
|
71
|
+
evts
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
events.each do |ev|
|
|
75
|
+
result = handle(event: ev)
|
|
76
|
+
@pending_mutex.synchronize { @pending << ev } if result == :at_capacity || result == :cooldown
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def spawn_event(event)
|
|
83
|
+
agent_id = event[:agent_id]
|
|
84
|
+
|
|
85
|
+
# Enrich event for workflow: team (team lead mode)
|
|
86
|
+
if @spawner_config[:workflow] == :team
|
|
87
|
+
event = enrich_for_team(event)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
@result = @spawner.spawn(event:, spawner_config: effective_spawner_config)
|
|
91
|
+
|
|
92
|
+
case @result[:status]
|
|
93
|
+
when :spawned
|
|
94
|
+
Superkick.logger.info("spawn_handler") { "Spawned agent #{@result[:agent_id]} for #{agent_id}" }
|
|
95
|
+
when :at_capacity
|
|
96
|
+
Superkick.logger.warn("spawn_handler") { "At capacity — queuing #{agent_id}" }
|
|
97
|
+
@pending_mutex.synchronize { @pending << event }
|
|
98
|
+
when :cooldown
|
|
99
|
+
Superkick.logger.info("spawn_handler") { "Cooldown active — queuing #{agent_id}" }
|
|
100
|
+
@pending_mutex.synchronize { @pending << event }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
@result[:status]
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Enrich a spawn event for workflow: team — add team metadata and
|
|
107
|
+
# repository source context so the planning agent can decompose work.
|
|
108
|
+
def enrich_for_team(event)
|
|
109
|
+
team_id = event[:agent_id]
|
|
110
|
+
event.merge(
|
|
111
|
+
team_id:,
|
|
112
|
+
team_role: :lead,
|
|
113
|
+
role: "Lead",
|
|
114
|
+
repository_context: @repository_source&.to_prompt_context,
|
|
115
|
+
prompt_template: "team/planning_agent",
|
|
116
|
+
_spawn_monitors: {
|
|
117
|
+
team_log: {type: :team_log, team_id:}
|
|
118
|
+
}
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Return the effective spawner config, applying driver cascade and
|
|
123
|
+
# team workflow defaults.
|
|
124
|
+
def effective_spawner_config
|
|
125
|
+
config = @spawner_config
|
|
126
|
+
if config[:workflow] == :team
|
|
127
|
+
config = config.merge(goal: {type: :agent_signal}) unless config[:goal]
|
|
128
|
+
|
|
129
|
+
lead_driver = config.dig(:team, :lead, :driver)
|
|
130
|
+
if lead_driver
|
|
131
|
+
config = config.merge(driver: Driver.merge_driver(config[:driver], lead_driver))
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
config
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def dispatch_notification(...)
|
|
138
|
+
@notification_dispatcher.dispatch(...)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Backwards-compatible alias
|
|
144
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Spawn
|
|
5
|
+
# Injector — handles injecting a kickoff prompt into a newly spawned
|
|
6
|
+
# agent. Extracted from Spawn::AgentSpawner to isolate injection protocol logic
|
|
7
|
+
# (buffer client polling, template rendering) from workspace setup and
|
|
8
|
+
# process management.
|
|
9
|
+
#
|
|
10
|
+
# Uses the agent's InjectionQueue with :high priority so spawn kickoffs
|
|
11
|
+
# are injected before any queued monitor events.
|
|
12
|
+
class Injector
|
|
13
|
+
WORKFLOW_TEMPLATES_DIR = File.join(__dir__, "..", "templates", "workflow")
|
|
14
|
+
|
|
15
|
+
def initialize(store:, buffer_client: nil)
|
|
16
|
+
@store = store
|
|
17
|
+
@buffer_client = buffer_client || Buffer.client_from(store:)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Inject a kickoff prompt into a spawned agent.
|
|
21
|
+
# Waits for the buffer to become reachable, renders the template,
|
|
22
|
+
# and sends the prompt via `enqueue_injection` with high priority.
|
|
23
|
+
def inject(agent_id:, event:, spawner_config:)
|
|
24
|
+
wait_for_buffer(agent_id)
|
|
25
|
+
|
|
26
|
+
prompt = render(event, spawner_config)
|
|
27
|
+
return unless prompt
|
|
28
|
+
|
|
29
|
+
enqueue_to_buffer(agent_id, prompt, event)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def wait_for_buffer(agent_id, timeout: 30)
|
|
35
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
36
|
+
loop do
|
|
37
|
+
break if @buffer_client.reachable?(agent_id)
|
|
38
|
+
if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
|
|
39
|
+
Superkick.logger.warn("spawn_injector") { "Buffer not reachable for #{agent_id} — skipping kickoff" }
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
sleep 0.5
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def render(event, spawner_config)
|
|
47
|
+
template_path = resolve_spawn_template(event, spawner_config)
|
|
48
|
+
return nil unless template_path
|
|
49
|
+
|
|
50
|
+
source = File.read(template_path, encoding: "utf-8")
|
|
51
|
+
render_event = rehydrate_context(event)
|
|
52
|
+
TemplateRenderer.render_source(source, render_event, monitor_type: event[:monitor_type])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Rehydrate serialized Drop values in the event so templates get real
|
|
56
|
+
# Drop objects ({{ issue.title }}, {{ story.ref }}, etc.).
|
|
57
|
+
# Serialized context comes from workflow forwarding where Drops were
|
|
58
|
+
# serialized to hashes with _drop_type markers.
|
|
59
|
+
def rehydrate_context(event)
|
|
60
|
+
event.transform_values { Superkick::Drop.rehydrate(it) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def enqueue_to_buffer(agent_id, prompt, event)
|
|
64
|
+
@buffer_client.send_command(agent_id, "enqueue_injection",
|
|
65
|
+
id: "spawn-#{agent_id}",
|
|
66
|
+
prompt:,
|
|
67
|
+
monitor_type: event[:monitor_type] || "spawner",
|
|
68
|
+
monitor_name: event[:monitor_name] || "system",
|
|
69
|
+
priority: "high",
|
|
70
|
+
ttl: 600)
|
|
71
|
+
|
|
72
|
+
Superkick.logger.info("spawn_injector") { "Enqueued kickoff prompt for #{agent_id}" }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def resolve_spawn_template(event, _spawner_config)
|
|
76
|
+
monitor_type = event[:monitor_type].to_s
|
|
77
|
+
monitor_name = event[:monitor_name]&.to_s
|
|
78
|
+
event_type = event[:event_type].to_s
|
|
79
|
+
|
|
80
|
+
# 1. User per-instance: ~/.superkick/templates/spawners/<name>/<event>.liquid
|
|
81
|
+
if monitor_name
|
|
82
|
+
path = File.join(Superkick.config.templates_dir, "spawners", monitor_name, "#{event_type}.liquid")
|
|
83
|
+
return path if File.exist?(path)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# 2. User per-type: ~/.superkick/templates/spawners/<type>/<event>.liquid
|
|
87
|
+
path = File.join(Superkick.config.templates_dir, "spawners", monitor_type, "#{event_type}.liquid")
|
|
88
|
+
return path if File.exist?(path)
|
|
89
|
+
|
|
90
|
+
# 2b. Workflow default: ~/.superkick/templates/spawners/workflow/<event>.liquid
|
|
91
|
+
if monitor_type == "workflow"
|
|
92
|
+
path = File.join(Superkick.config.templates_dir, "spawners", "workflow", "#{event_type}.liquid")
|
|
93
|
+
return path if File.exist?(path)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# 3. Bundled spawner templates from the spawner class
|
|
97
|
+
klass = Spawner.registered[monitor_type.to_sym]
|
|
98
|
+
if klass&.spawn_templates_dir
|
|
99
|
+
path = File.join(klass.spawn_templates_dir, "#{event_type}.liquid")
|
|
100
|
+
return path if File.exist?(path)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# 4. Bundled workflow default template
|
|
104
|
+
if monitor_type == "workflow"
|
|
105
|
+
path = File.join(WORKFLOW_TEMPLATES_DIR, "#{event_type}.liquid")
|
|
106
|
+
return path if File.exist?(path)
|
|
107
|
+
|
|
108
|
+
# Fall back to workflow_triggered.liquid for any workflow event type
|
|
109
|
+
path = File.join(WORKFLOW_TEMPLATES_DIR, "workflow_triggered.liquid")
|
|
110
|
+
return path if File.exist?(path)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Backwards-compatible alias
|
|
119
|
+
end
|