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,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Spawn
|
|
5
|
+
# WorkflowExecutor — resolves workflow config, builds enriched events, handles
|
|
6
|
+
# VCS ownership transfer, enforces iteration limits, and dispatches
|
|
7
|
+
# workflow spawns.
|
|
8
|
+
#
|
|
9
|
+
# Called by Supervisor when a spawned agent's goal reaches a terminal status
|
|
10
|
+
# and the spawner config has `on_complete:` or `on_fail:`.
|
|
11
|
+
class WorkflowExecutor
|
|
12
|
+
def initialize(store:, agent_spawner:, notification_dispatcher:, config: Superkick.config)
|
|
13
|
+
@store = store
|
|
14
|
+
@agent_spawner = agent_spawner
|
|
15
|
+
@notification_dispatcher = notification_dispatcher
|
|
16
|
+
@config = config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Fire a workflow spawn if configured.
|
|
20
|
+
# @param agent_id [String] the parent agent that just finished
|
|
21
|
+
# @param goal_status [Symbol] :completed or :failed
|
|
22
|
+
# @param spawner_config [Hash] the parent spawner's config
|
|
23
|
+
def fire(agent_id:, goal_status:, spawner_config:)
|
|
24
|
+
hook_key = case goal_status
|
|
25
|
+
when :completed then :on_complete
|
|
26
|
+
when :failed then :on_fail
|
|
27
|
+
else return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
hook_config = spawner_config[hook_key]
|
|
31
|
+
return unless hook_config
|
|
32
|
+
|
|
33
|
+
agent = @store.get(agent_id)
|
|
34
|
+
current_depth = agent&.spawn_info&.dig(:workflow_depth) || 0
|
|
35
|
+
parent_iterations = agent&.spawn_info&.dig(:workflow_iterations) || {}
|
|
36
|
+
|
|
37
|
+
resolved = resolve_workflow_config(hook_config, spawner_config)
|
|
38
|
+
return unless resolved
|
|
39
|
+
|
|
40
|
+
# Check iteration limit for the target spawner
|
|
41
|
+
target_name = resolved[:name]&.to_sym
|
|
42
|
+
if target_name
|
|
43
|
+
current_count = parent_iterations[target_name] || 0
|
|
44
|
+
max = resolved[:max_iterations]
|
|
45
|
+
if max && current_count >= max
|
|
46
|
+
Superkick.logger.warn("workflow_executor") {
|
|
47
|
+
"Max iterations reached for #{target_name} (#{current_count}/#{max}) in chain for #{agent_id} — not spawning"
|
|
48
|
+
}
|
|
49
|
+
dispatch_notification(
|
|
50
|
+
event: {event_type: :workflow_iterations_exceeded, monitor_type: :spawner,
|
|
51
|
+
monitor_name: spawner_config[:name] || :system,
|
|
52
|
+
goal_status:},
|
|
53
|
+
agent_id:,
|
|
54
|
+
message: "Max iterations reached for #{target_name} (#{current_count}/#{max}) in chain for #{agent_id}"
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
workflow_event = build_workflow_event(
|
|
61
|
+
agent_id:, goal_status:, spawner_config:,
|
|
62
|
+
workflow_config: resolved, depth: current_depth,
|
|
63
|
+
parent_iterations:
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
# VCS ownership transfer for inherit case
|
|
67
|
+
if hook_config[:repository].nil?
|
|
68
|
+
vcs_state = @agent_spawner.take_vcs_state(agent_id)
|
|
69
|
+
|
|
70
|
+
if vcs_state
|
|
71
|
+
workflow_event[:inherited_vcs_state] = vcs_state
|
|
72
|
+
workflow_event[:working_dir] = vcs_state[:destination] ||
|
|
73
|
+
agent&.spawn_info&.dig(:working_dir)
|
|
74
|
+
else
|
|
75
|
+
# No VCS state to transfer — pass parent working dir
|
|
76
|
+
workflow_event[:working_dir] = agent&.spawn_info&.dig(:working_dir)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Fire notification
|
|
81
|
+
dispatch_notification(
|
|
82
|
+
event: {event_type: :workflow_triggered, monitor_type: :spawner,
|
|
83
|
+
monitor_name: spawner_config[:name] || :system,
|
|
84
|
+
goal_status:},
|
|
85
|
+
agent_id:,
|
|
86
|
+
message: "Workflow triggered for #{agent_id} (#{goal_status} → #{resolved[:name]})"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
# Spawn in background thread
|
|
90
|
+
Thread.new do
|
|
91
|
+
@agent_spawner.spawn(event: workflow_event, spawner_config: resolved)
|
|
92
|
+
rescue => e
|
|
93
|
+
Superkick.logger.error("workflow_executor") {
|
|
94
|
+
"Workflow spawn failed for #{agent_id}: #{e.message}"
|
|
95
|
+
}
|
|
96
|
+
# Reclaim VCS state if spawn fails to avoid orphaned workspaces
|
|
97
|
+
reclaim_vcs_state(workflow_event)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def resolve_workflow_config(workflow_config, parent_config)
|
|
104
|
+
if workflow_config[:spawner]
|
|
105
|
+
# Reference to a named spawner
|
|
106
|
+
name = workflow_config[:spawner].to_sym
|
|
107
|
+
ref = @config.spawners[name]
|
|
108
|
+
unless ref
|
|
109
|
+
Superkick.logger.warn("workflow_executor") { "Workflow references unknown spawner: #{name}" }
|
|
110
|
+
return nil
|
|
111
|
+
end
|
|
112
|
+
ref.merge(name:, workflow_source: parent_config[:name])
|
|
113
|
+
else
|
|
114
|
+
# Inline config — fill in defaults from parent, merging driver configs
|
|
115
|
+
effective_driver = if workflow_config[:driver]
|
|
116
|
+
Driver.merge_driver(parent_config[:driver], workflow_config[:driver])
|
|
117
|
+
else
|
|
118
|
+
parent_config[:driver]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
{
|
|
122
|
+
name: "#{parent_config[:name]}-workflow",
|
|
123
|
+
driver: effective_driver,
|
|
124
|
+
goal: workflow_config[:goal],
|
|
125
|
+
repository: workflow_config[:repository],
|
|
126
|
+
max_duration: workflow_config[:max_duration] || parent_config[:max_duration],
|
|
127
|
+
max_concurrent: workflow_config[:max_concurrent],
|
|
128
|
+
approval_required: workflow_config[:approval_required] || false,
|
|
129
|
+
prompt_template: workflow_config[:prompt_template],
|
|
130
|
+
workflow_source: parent_config[:name]
|
|
131
|
+
}.compact
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def build_workflow_event(agent_id:, goal_status:, spawner_config:, workflow_config:, depth:, parent_iterations:)
|
|
136
|
+
agent = @store.get(agent_id)
|
|
137
|
+
original_event = agent&.spawn_info || {}
|
|
138
|
+
|
|
139
|
+
# Determine event_type for template resolution
|
|
140
|
+
event_type = workflow_config[:prompt_template] || :workflow_triggered
|
|
141
|
+
|
|
142
|
+
# Determine agent_id for the workflow spawn
|
|
143
|
+
stage = workflow_config[:name] || ((goal_status == :completed) ? "on_complete" : "on_fail")
|
|
144
|
+
workflow_agent_id = "#{agent_id}-workflow-#{stage}"
|
|
145
|
+
|
|
146
|
+
# Increment iteration count for the target spawner
|
|
147
|
+
target_name = workflow_config[:name]&.to_sym
|
|
148
|
+
workflow_iterations = parent_iterations.dup
|
|
149
|
+
workflow_iterations[target_name] = (workflow_iterations[target_name] || 0) + 1 if target_name
|
|
150
|
+
|
|
151
|
+
event = {
|
|
152
|
+
event_type:,
|
|
153
|
+
agent_id: workflow_agent_id,
|
|
154
|
+
monitor_type: :workflow,
|
|
155
|
+
monitor_name: spawner_config[:name] || :system,
|
|
156
|
+
parent_agent_id: agent_id,
|
|
157
|
+
parent_goal_status: goal_status,
|
|
158
|
+
parent_spawner_name: spawner_config[:name],
|
|
159
|
+
workflow_depth: depth + 1,
|
|
160
|
+
workflow_iterations:
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
# Forward serialized context from parent's spawn_info wholesale.
|
|
164
|
+
# This replaces the old FORWARDED_FIELDS approach — context carries
|
|
165
|
+
# all integration-specific data (Drops, scalars) without enumeration.
|
|
166
|
+
original_event[:context]&.each do |key, value|
|
|
167
|
+
event[key] = value
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Include failure retrospective when available
|
|
171
|
+
event[:parent_goal_summary] = agent.goal_summary if agent&.goal_summary
|
|
172
|
+
|
|
173
|
+
# Forward per-agent notifier configs so workflow children inherit them
|
|
174
|
+
event[:_spawn_notifiers] = agent.notifiers if agent&.notifiers&.any?
|
|
175
|
+
|
|
176
|
+
event
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Reclaim VCS resources from a failed workflow event to avoid orphaning.
|
|
180
|
+
def reclaim_vcs_state(workflow_event)
|
|
181
|
+
vcs_state = workflow_event.delete(:inherited_vcs_state)
|
|
182
|
+
vcs_state[:adapter].teardown(destination: vcs_state[:destination]) if vcs_state
|
|
183
|
+
rescue => e
|
|
184
|
+
Superkick.logger.error("workflow_executor") {
|
|
185
|
+
"VCS reclaim teardown failed: #{e.message}"
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def dispatch_notification(...)
|
|
190
|
+
@notification_dispatcher.dispatch(...)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Backwards-compatible alias
|
|
196
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
module Spawn
|
|
5
|
+
# WorkflowValidator — static validation for spawner workflow configs.
|
|
6
|
+
#
|
|
7
|
+
# Two checks, both called at server startup:
|
|
8
|
+
#
|
|
9
|
+
# 1. **Cycle detection** — walks `on_complete:` / `on_fail:` spawner
|
|
10
|
+
# reference chains looking for cycles via visited-set tracking.
|
|
11
|
+
#
|
|
12
|
+
# 2. **Cyclic iteration requirement** — spawners with `allow_cycles: true`
|
|
13
|
+
# must have an explicit `max_iterations` to prevent infinite recursion.
|
|
14
|
+
#
|
|
15
|
+
# Only `:spawner` references create chain links — inline configs are leaf
|
|
16
|
+
# nodes and cannot form cycles.
|
|
17
|
+
class WorkflowValidator
|
|
18
|
+
# Validate all spawner configs for cycles.
|
|
19
|
+
# @param spawners [Hash] the spawners config hash (name => config)
|
|
20
|
+
# @return [Array<Array<Symbol>>] list of cycles found (empty = valid)
|
|
21
|
+
def self.validate(spawners)
|
|
22
|
+
cycles = []
|
|
23
|
+
spawners.each_key do |name|
|
|
24
|
+
walk_chain(name.to_sym, spawners, {}, [], cycles)
|
|
25
|
+
end
|
|
26
|
+
cycles
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Find cyclic spawners that lack a max_iterations limit.
|
|
30
|
+
#
|
|
31
|
+
# Spawners with `allow_cycles: true` need an explicit iteration limit
|
|
32
|
+
# to prevent infinite recursion.
|
|
33
|
+
#
|
|
34
|
+
# @param spawners [Hash] the spawners config hash
|
|
35
|
+
# @return [Array<Symbol>] names of spawners missing an iteration limit
|
|
36
|
+
def self.cyclic_spawners_without_iteration_limit(spawners)
|
|
37
|
+
spawners.each_with_object([]) do |(name, config), missing|
|
|
38
|
+
next unless config[:allow_cycles]
|
|
39
|
+
|
|
40
|
+
missing << name.to_sym unless config[:max_iterations]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Return the spawner names reachable from a config's on_complete/on_fail hooks.
|
|
45
|
+
def self.successors(config)
|
|
46
|
+
%i[on_complete on_fail].filter_map do |hook|
|
|
47
|
+
config[hook]&.dig(:spawner)&.to_sym
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
private_class_method :successors
|
|
51
|
+
|
|
52
|
+
def self.walk_chain(spawner, spawners, visited, path, cycles)
|
|
53
|
+
return if visited[spawner] == :done
|
|
54
|
+
|
|
55
|
+
if visited[spawner] == :in_progress
|
|
56
|
+
cycle_start = path.index(spawner)
|
|
57
|
+
cycles << path[cycle_start..] + [spawner]
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
visited[spawner] = :in_progress
|
|
62
|
+
path.push(spawner)
|
|
63
|
+
|
|
64
|
+
config = spawners[spawner]
|
|
65
|
+
successors(config || {}).each do |target|
|
|
66
|
+
walk_chain(target, spawners, visited, path, cycles)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
path.pop
|
|
70
|
+
visited[spawner] = :done
|
|
71
|
+
end
|
|
72
|
+
private_class_method :walk_chain
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Backwards-compatible alias
|
|
77
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Spawner — server-level poller base class for spawning agents.
|
|
5
|
+
#
|
|
6
|
+
# Inherits the run loop, error handling, backoff, and config validation
|
|
7
|
+
# from Poller. Differences from agent-bound monitors:
|
|
8
|
+
#
|
|
9
|
+
# 1. No @agent — spawners are not bound to any agent
|
|
10
|
+
# 2. Separate class-level registry — types don't collide with Monitor types
|
|
11
|
+
# 3. dispatch stamps an agent_id on the event
|
|
12
|
+
# 4. log_tag doesn't reference an agent ID
|
|
13
|
+
#
|
|
14
|
+
# Subclass contract (in addition to Poller's):
|
|
15
|
+
# self.agent_id(event) → String — deterministic agent ID for dedup
|
|
16
|
+
# self.spawn_templates_dir → path to kickoff ERB templates (optional)
|
|
17
|
+
class Spawner < Poller
|
|
18
|
+
@registry = {}
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
include Superkick::Registry
|
|
22
|
+
|
|
23
|
+
def register(klass)
|
|
24
|
+
raise ArgumentError, "#{klass} must define self.type" unless klass.respond_to?(:type)
|
|
25
|
+
key = klass.type.to_sym
|
|
26
|
+
raise ArgumentError, "Spawner type :#{key} already registered" if @registry.key?(key)
|
|
27
|
+
@registry[key] = klass
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def deregister(type)
|
|
31
|
+
@registry.delete(type.to_sym)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def registered = @registry.dup.freeze
|
|
35
|
+
|
|
36
|
+
def lookup(type)
|
|
37
|
+
@registry[type.to_sym] or raise ArgumentError, "Unknown spawner type: #{type}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Subclasses must implement: given an event hash, return a string that
|
|
42
|
+
# uniquely identifies the work unit and serves as the agent ID.
|
|
43
|
+
def self.agent_id(event)
|
|
44
|
+
raise NotImplementedError, "#{self}.agent_id not implemented"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Override to provide spawn-specific template directory.
|
|
48
|
+
def self.spawn_templates_dir
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Override dispatch to stamp agent_id from the spawner class.
|
|
53
|
+
def dispatch(event)
|
|
54
|
+
full_event = event.merge(
|
|
55
|
+
monitor_type: self.class.type.to_s,
|
|
56
|
+
monitor_name: @name,
|
|
57
|
+
agent_id: self.class.agent_id(event)
|
|
58
|
+
)
|
|
59
|
+
@handler.handle(event: full_event)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# No agent — use spawner-specific tag.
|
|
63
|
+
def log_tag
|
|
64
|
+
"spawner:#{self.class.type}(#{@name})"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|