roast-ai 0.1.7 → 0.2.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 +4 -4
- data/.github/workflows/ci.yaml +1 -1
- data/CHANGELOG.md +40 -1
- data/CLAUDE.md +20 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +9 -6
- data/README.md +81 -14
- data/bin/roast +27 -0
- data/docs/ITERATION_SYNTAX.md +119 -0
- data/examples/conditional/README.md +161 -0
- data/examples/conditional/check_condition/prompt.md +1 -0
- data/examples/conditional/simple_workflow.yml +15 -0
- data/examples/conditional/workflow.yml +23 -0
- data/examples/dot_notation/README.md +37 -0
- data/examples/dot_notation/workflow.yml +44 -0
- data/examples/exit_on_error/README.md +50 -0
- data/examples/exit_on_error/analyze_lint_output/prompt.md +9 -0
- data/examples/exit_on_error/apply_fixes/prompt.md +2 -0
- data/examples/exit_on_error/workflow.yml +19 -0
- data/examples/grading/workflow.yml +5 -1
- data/examples/iteration/IMPLEMENTATION.md +88 -0
- data/examples/iteration/README.md +68 -0
- data/examples/iteration/analyze_complexity/prompt.md +22 -0
- data/examples/iteration/generate_recommendations/prompt.md +21 -0
- data/examples/iteration/generate_report/prompt.md +129 -0
- data/examples/iteration/implement_fix/prompt.md +25 -0
- data/examples/iteration/prioritize_issues/prompt.md +24 -0
- data/examples/iteration/prompts/analyze_file.md +28 -0
- data/examples/iteration/prompts/generate_summary.md +24 -0
- data/examples/iteration/prompts/update_report.md +29 -0
- data/examples/iteration/prompts/write_report.md +22 -0
- data/examples/iteration/read_file/prompt.md +9 -0
- data/examples/iteration/select_next_issue/prompt.md +25 -0
- data/examples/iteration/simple_workflow.md +39 -0
- data/examples/iteration/simple_workflow.yml +58 -0
- data/examples/iteration/update_fix_count/prompt.md +26 -0
- data/examples/iteration/verify_fix/prompt.md +29 -0
- data/examples/iteration/workflow.yml +42 -0
- data/examples/openrouter_example/workflow.yml +2 -2
- data/examples/workflow_generator/README.md +27 -0
- data/examples/workflow_generator/analyze_user_request/prompt.md +34 -0
- data/examples/workflow_generator/create_workflow_files/prompt.md +32 -0
- data/examples/workflow_generator/get_user_input/prompt.md +14 -0
- data/examples/workflow_generator/info_from_roast.rb +22 -0
- data/examples/workflow_generator/workflow.yml +35 -0
- data/lib/roast/errors.rb +9 -0
- data/lib/roast/factories/api_provider_factory.rb +61 -0
- data/lib/roast/helpers/function_caching_interceptor.rb +1 -1
- data/lib/roast/helpers/minitest_coverage_runner.rb +1 -1
- data/lib/roast/helpers/prompt_loader.rb +50 -1
- data/lib/roast/resources/base_resource.rb +7 -0
- data/lib/roast/resources.rb +6 -6
- data/lib/roast/tools/ask_user.rb +40 -0
- data/lib/roast/tools/cmd.rb +1 -1
- data/lib/roast/tools/search_file.rb +1 -1
- data/lib/roast/tools.rb +11 -1
- data/lib/roast/value_objects/api_token.rb +49 -0
- data/lib/roast/value_objects/step_name.rb +39 -0
- data/lib/roast/value_objects/workflow_path.rb +77 -0
- data/lib/roast/value_objects.rb +5 -0
- data/lib/roast/version.rb +1 -1
- data/lib/roast/workflow/api_configuration.rb +61 -0
- data/lib/roast/workflow/base_iteration_step.rb +165 -0
- data/lib/roast/workflow/base_step.rb +4 -24
- data/lib/roast/workflow/base_workflow.rb +76 -73
- data/lib/roast/workflow/command_executor.rb +88 -0
- data/lib/roast/workflow/conditional_executor.rb +50 -0
- data/lib/roast/workflow/conditional_step.rb +96 -0
- data/lib/roast/workflow/configuration.rb +35 -158
- data/lib/roast/workflow/configuration_loader.rb +78 -0
- data/lib/roast/workflow/configuration_parser.rb +13 -248
- data/lib/roast/workflow/context_path_resolver.rb +43 -0
- data/lib/roast/workflow/dot_access_hash.rb +198 -0
- data/lib/roast/workflow/each_step.rb +86 -0
- data/lib/roast/workflow/error_handler.rb +97 -0
- data/lib/roast/workflow/expression_utils.rb +36 -0
- data/lib/roast/workflow/file_state_repository.rb +3 -2
- data/lib/roast/workflow/interpolator.rb +34 -0
- data/lib/roast/workflow/iteration_executor.rb +85 -0
- data/lib/roast/workflow/llm_boolean_coercer.rb +55 -0
- data/lib/roast/workflow/output_handler.rb +35 -0
- data/lib/roast/workflow/output_manager.rb +77 -0
- data/lib/roast/workflow/parallel_executor.rb +49 -0
- data/lib/roast/workflow/repeat_step.rb +75 -0
- data/lib/roast/workflow/replay_handler.rb +123 -0
- data/lib/roast/workflow/resource_resolver.rb +77 -0
- data/lib/roast/workflow/session_manager.rb +6 -2
- data/lib/roast/workflow/state_manager.rb +97 -0
- data/lib/roast/workflow/step_executor_coordinator.rb +205 -0
- data/lib/roast/workflow/step_executor_factory.rb +47 -0
- data/lib/roast/workflow/step_executor_registry.rb +79 -0
- data/lib/roast/workflow/step_executors/base_step_executor.rb +23 -0
- data/lib/roast/workflow/step_executors/hash_step_executor.rb +43 -0
- data/lib/roast/workflow/step_executors/parallel_step_executor.rb +54 -0
- data/lib/roast/workflow/step_executors/string_step_executor.rb +29 -0
- data/lib/roast/workflow/step_finder.rb +97 -0
- data/lib/roast/workflow/step_loader.rb +154 -0
- data/lib/roast/workflow/step_orchestrator.rb +45 -0
- data/lib/roast/workflow/step_runner.rb +23 -0
- data/lib/roast/workflow/step_type_resolver.rb +117 -0
- data/lib/roast/workflow/workflow_context.rb +60 -0
- data/lib/roast/workflow/workflow_executor.rb +90 -209
- data/lib/roast/workflow/workflow_initializer.rb +112 -0
- data/lib/roast/workflow/workflow_runner.rb +87 -0
- data/lib/roast/workflow.rb +3 -0
- data/lib/roast.rb +96 -3
- data/roast.gemspec +2 -1
- data/schema/workflow.json +85 -0
- metadata +97 -4
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
require "roast/resources"
|
5
|
+
|
6
|
+
module Roast
|
7
|
+
module Workflow
|
8
|
+
# Handles resource resolution and target processing
|
9
|
+
# Extracts file/resource handling logic from Configuration
|
10
|
+
class ResourceResolver
|
11
|
+
class << self
|
12
|
+
# Process the target and create appropriate resource object
|
13
|
+
# @param target [String, nil] The target from configuration or options
|
14
|
+
# @param context_path [String] The directory containing the workflow file
|
15
|
+
# @return [Roast::Resources::BaseResource] The resolved resource object
|
16
|
+
def resolve(target, context_path)
|
17
|
+
return Roast::Resources::NoneResource.new(nil) unless has_target?(target)
|
18
|
+
|
19
|
+
processed_target = process_target(target, context_path)
|
20
|
+
Roast::Resources.for(processed_target)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Process target through shell command expansion and glob pattern matching
|
24
|
+
# @param target [String] The raw target string
|
25
|
+
# @param context_path [String] The directory containing the workflow file
|
26
|
+
# @return [String] The processed target
|
27
|
+
def process_target(target, context_path)
|
28
|
+
# Process shell command first
|
29
|
+
processed = process_shell_command(target)
|
30
|
+
|
31
|
+
# If it's a glob pattern, return the full paths of the files it matches
|
32
|
+
if processed.include?("*")
|
33
|
+
matched_files = Dir.glob(processed)
|
34
|
+
# If no files match, return the pattern itself
|
35
|
+
return processed if matched_files.empty?
|
36
|
+
|
37
|
+
return matched_files.map { |file| File.expand_path(file) }.join("\n")
|
38
|
+
end
|
39
|
+
|
40
|
+
# For tests, if the command was already processed as a shell command and is simple,
|
41
|
+
# don't expand the path to avoid breaking existing tests
|
42
|
+
return processed if target != processed && !processed.include?("/")
|
43
|
+
|
44
|
+
# Don't expand URLs
|
45
|
+
return processed if processed.match?(%r{^https?://})
|
46
|
+
|
47
|
+
# assumed to be a direct file path
|
48
|
+
File.expand_path(processed)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Process shell commands in $(command) or legacy % format
|
52
|
+
# @param command [String] The command string
|
53
|
+
# @return [String] The command output or original string if not a shell command
|
54
|
+
def process_shell_command(command)
|
55
|
+
# If it's a bash command with the $(command) syntax
|
56
|
+
if command =~ /^\$\((.*)\)$/
|
57
|
+
return Open3.capture2e({}, ::Regexp.last_match(1)).first.strip
|
58
|
+
end
|
59
|
+
|
60
|
+
# Legacy % prefix for backward compatibility
|
61
|
+
if command.start_with?("% ")
|
62
|
+
return Open3.capture2e({}, *command.split(" ")[1..-1]).first.strip
|
63
|
+
end
|
64
|
+
|
65
|
+
# Not a shell command, return as is
|
66
|
+
command
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def has_target?(target)
|
72
|
+
!target.nil? && !target.empty?
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -7,6 +7,8 @@ module Roast
|
|
7
7
|
module Workflow
|
8
8
|
# Manages session creation, timestamping, and directory management
|
9
9
|
class SessionManager
|
10
|
+
TARGETLESS_FILE_PATH = "notarget"
|
11
|
+
|
10
12
|
def initialize
|
11
13
|
@session_mutex = Mutex.new
|
12
14
|
@session_timestamps = {}
|
@@ -66,9 +68,11 @@ module Roast
|
|
66
68
|
private
|
67
69
|
|
68
70
|
def workflow_directory(session_name, file_path)
|
71
|
+
file_path ||= TARGETLESS_FILE_PATH
|
69
72
|
workflow_dir_name = session_name.parameterize.underscore
|
70
|
-
|
71
|
-
|
73
|
+
# For targetless sessions we don't have a file_path
|
74
|
+
file_id = Digest::MD5.hexdigest(file_path || Dir.pwd)
|
75
|
+
file_basename = File.basename(file_path || Dir.pwd).parameterize.underscore
|
72
76
|
human_readable_id = "#{file_basename}_#{file_id[0..7]}"
|
73
77
|
File.join(Dir.pwd, ".roast", "sessions", workflow_dir_name, human_readable_id)
|
74
78
|
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/workflow/file_state_repository"
|
4
|
+
|
5
|
+
module Roast
|
6
|
+
module Workflow
|
7
|
+
# Manages workflow state persistence and restoration
|
8
|
+
class StateManager
|
9
|
+
attr_reader :workflow, :logger
|
10
|
+
|
11
|
+
def initialize(workflow, logger: nil)
|
12
|
+
@workflow = workflow
|
13
|
+
@logger = logger
|
14
|
+
@state_repository = FileStateRepository.new
|
15
|
+
end
|
16
|
+
|
17
|
+
# Save the current state after a step execution
|
18
|
+
#
|
19
|
+
# @param step_name [String] The name of the step that just completed
|
20
|
+
# @param step_result [Object] The result of the step execution
|
21
|
+
def save_state(step_name, step_result)
|
22
|
+
return unless should_save_state?
|
23
|
+
|
24
|
+
state_data = build_state_data(step_name, step_result)
|
25
|
+
@state_repository.save_state(workflow, step_name, state_data)
|
26
|
+
rescue => e
|
27
|
+
# Don't fail the workflow if state saving fails
|
28
|
+
log_warning("Failed to save workflow state: #{e.message}")
|
29
|
+
end
|
30
|
+
|
31
|
+
# Check if state should be saved for the current workflow
|
32
|
+
#
|
33
|
+
# @return [Boolean] true if state should be saved
|
34
|
+
def should_save_state?
|
35
|
+
workflow.respond_to?(:session_name) && workflow.session_name
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
# Build the state data structure for persistence
|
41
|
+
def build_state_data(step_name, step_result)
|
42
|
+
{
|
43
|
+
step_name: step_name,
|
44
|
+
order: determine_step_order(step_name),
|
45
|
+
transcript: extract_transcript,
|
46
|
+
output: extract_output,
|
47
|
+
final_output: extract_final_output,
|
48
|
+
execution_order: extract_execution_order,
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
52
|
+
# Determine the order of the step in the workflow
|
53
|
+
def determine_step_order(step_name)
|
54
|
+
return 0 unless workflow.respond_to?(:output)
|
55
|
+
|
56
|
+
workflow.output.keys.index(step_name) || workflow.output.size
|
57
|
+
end
|
58
|
+
|
59
|
+
# Extract transcript data if available
|
60
|
+
def extract_transcript
|
61
|
+
return [] unless workflow.respond_to?(:transcript)
|
62
|
+
|
63
|
+
workflow.transcript.map(&:itself)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Extract output data if available
|
67
|
+
def extract_output
|
68
|
+
return {} unless workflow.respond_to?(:output)
|
69
|
+
|
70
|
+
workflow.output.clone
|
71
|
+
end
|
72
|
+
|
73
|
+
# Extract final output data if available
|
74
|
+
def extract_final_output
|
75
|
+
return [] unless workflow.respond_to?(:final_output)
|
76
|
+
|
77
|
+
workflow.final_output.clone
|
78
|
+
end
|
79
|
+
|
80
|
+
# Extract execution order from workflow output
|
81
|
+
def extract_execution_order
|
82
|
+
return [] unless workflow.respond_to?(:output)
|
83
|
+
|
84
|
+
workflow.output.keys
|
85
|
+
end
|
86
|
+
|
87
|
+
# Log a warning message
|
88
|
+
def log_warning(message)
|
89
|
+
if logger
|
90
|
+
logger.warn(message)
|
91
|
+
else
|
92
|
+
$stderr.puts "WARNING: #{message}"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,205 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/workflow/conditional_executor"
|
4
|
+
require "roast/workflow/step_executor_factory"
|
5
|
+
require "roast/workflow/step_type_resolver"
|
6
|
+
|
7
|
+
module Roast
|
8
|
+
module Workflow
|
9
|
+
# Coordinates the execution of different types of steps
|
10
|
+
#
|
11
|
+
# This class is responsible for routing steps to their appropriate executors
|
12
|
+
# based on the step type. It acts as a central dispatcher that determines
|
13
|
+
# which execution strategy to use for each step.
|
14
|
+
#
|
15
|
+
# Current Architecture:
|
16
|
+
# - WorkflowExecutor.execute_steps still handles basic routing for backward compatibility
|
17
|
+
# - This coordinator is used by WorkflowExecutor.execute_step for named steps
|
18
|
+
# - Some step types (parallel) use the StepExecutorFactory pattern
|
19
|
+
# - Other step types use direct execution methods
|
20
|
+
#
|
21
|
+
# TODO: Future refactoring should move all execution logic from WorkflowExecutor
|
22
|
+
# to this coordinator and use the factory pattern consistently for all step types.
|
23
|
+
class StepExecutorCoordinator
|
24
|
+
def initialize(context:, dependencies:)
|
25
|
+
@context = context
|
26
|
+
@dependencies = dependencies
|
27
|
+
end
|
28
|
+
|
29
|
+
# Execute a list of steps
|
30
|
+
def execute_steps(workflow_steps)
|
31
|
+
workflow_steps.each do |step|
|
32
|
+
case step
|
33
|
+
when Hash
|
34
|
+
execute(step)
|
35
|
+
when Array
|
36
|
+
execute(step)
|
37
|
+
when String
|
38
|
+
execute(step)
|
39
|
+
# Handle pause after string steps
|
40
|
+
if @context.workflow.pause_step_name == step
|
41
|
+
Kernel.binding.irb # rubocop:disable Lint/Debugger
|
42
|
+
end
|
43
|
+
else
|
44
|
+
step_orchestrator.execute_step(step)
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
# Execute a single step (alias for compatibility)
|
50
|
+
def execute_step(step, options = {})
|
51
|
+
execute(step, options)
|
52
|
+
end
|
53
|
+
|
54
|
+
# Execute a step based on its type
|
55
|
+
# @param step [String, Hash, Array] The step to execute
|
56
|
+
# @param options [Hash] Execution options
|
57
|
+
# @return [Object] The result of the step execution
|
58
|
+
def execute(step, options = {})
|
59
|
+
step_type = StepTypeResolver.resolve(step, @context)
|
60
|
+
|
61
|
+
case step_type
|
62
|
+
when StepTypeResolver::COMMAND_STEP
|
63
|
+
# Command steps should also go through interpolation
|
64
|
+
execute_string_step(step, options)
|
65
|
+
when StepTypeResolver::GLOB_STEP
|
66
|
+
execute_glob_step(step)
|
67
|
+
when StepTypeResolver::ITERATION_STEP
|
68
|
+
execute_iteration_step(step)
|
69
|
+
when StepTypeResolver::CONDITIONAL_STEP
|
70
|
+
execute_conditional_step(step)
|
71
|
+
when StepTypeResolver::HASH_STEP
|
72
|
+
execute_hash_step(step)
|
73
|
+
when StepTypeResolver::PARALLEL_STEP
|
74
|
+
# Use factory for parallel steps
|
75
|
+
executor = StepExecutorFactory.for(step, workflow_executor)
|
76
|
+
executor.execute(step)
|
77
|
+
when StepTypeResolver::STRING_STEP
|
78
|
+
execute_string_step(step, options)
|
79
|
+
else
|
80
|
+
execute_standard_step(step, options)
|
81
|
+
end
|
82
|
+
end
|
83
|
+
|
84
|
+
private
|
85
|
+
|
86
|
+
attr_reader :context, :dependencies
|
87
|
+
|
88
|
+
def workflow_executor
|
89
|
+
dependencies[:workflow_executor]
|
90
|
+
end
|
91
|
+
|
92
|
+
def interpolator
|
93
|
+
dependencies[:interpolator]
|
94
|
+
end
|
95
|
+
|
96
|
+
def command_executor
|
97
|
+
dependencies[:command_executor]
|
98
|
+
end
|
99
|
+
|
100
|
+
def iteration_executor
|
101
|
+
dependencies[:iteration_executor]
|
102
|
+
end
|
103
|
+
|
104
|
+
def conditional_executor
|
105
|
+
dependencies[:conditional_executor]
|
106
|
+
end
|
107
|
+
|
108
|
+
def step_orchestrator
|
109
|
+
dependencies[:step_orchestrator]
|
110
|
+
end
|
111
|
+
|
112
|
+
def error_handler
|
113
|
+
dependencies[:error_handler]
|
114
|
+
end
|
115
|
+
|
116
|
+
def execute_command_step(step, options)
|
117
|
+
exit_on_error = options.fetch(:exit_on_error, true)
|
118
|
+
resource_type = @context.resource_type
|
119
|
+
|
120
|
+
error_handler.with_error_handling(step, resource_type: resource_type) do
|
121
|
+
$stderr.puts "Executing: #{step} (Resource type: #{resource_type || "unknown"})"
|
122
|
+
|
123
|
+
output = command_executor.execute(step, exit_on_error: exit_on_error)
|
124
|
+
|
125
|
+
# Add to transcript
|
126
|
+
workflow = context.workflow
|
127
|
+
workflow.transcript << {
|
128
|
+
user: "I just executed the following command: ```\n#{step}\n```\n\nHere is the output:\n\n```\n#{output}\n```",
|
129
|
+
}
|
130
|
+
workflow.transcript << { assistant: "Noted, thank you." }
|
131
|
+
|
132
|
+
output
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def execute_glob_step(step)
|
137
|
+
Dir.glob(step).join("\n")
|
138
|
+
end
|
139
|
+
|
140
|
+
def execute_iteration_step(step)
|
141
|
+
name = step.keys.first
|
142
|
+
command = step[name]
|
143
|
+
|
144
|
+
case name
|
145
|
+
when "repeat"
|
146
|
+
iteration_executor.execute_repeat(command)
|
147
|
+
when "each"
|
148
|
+
validate_each_step!(step)
|
149
|
+
iteration_executor.execute_each(step)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
def execute_conditional_step(step)
|
154
|
+
conditional_executor.execute_conditional(step)
|
155
|
+
end
|
156
|
+
|
157
|
+
def execute_hash_step(step)
|
158
|
+
name, command = step.to_a.flatten
|
159
|
+
interpolated_name = interpolator.interpolate(name)
|
160
|
+
|
161
|
+
if command.is_a?(Hash)
|
162
|
+
execute_steps([command])
|
163
|
+
else
|
164
|
+
interpolated_command = interpolator.interpolate(command)
|
165
|
+
exit_on_error = context.exit_on_error?(interpolated_name)
|
166
|
+
|
167
|
+
# Execute the command directly using the appropriate executor
|
168
|
+
result = execute(interpolated_command, { exit_on_error: exit_on_error })
|
169
|
+
context.workflow.output[interpolated_name] = result
|
170
|
+
result
|
171
|
+
end
|
172
|
+
end
|
173
|
+
|
174
|
+
def execute_string_step(step, options = {})
|
175
|
+
# Check for glob before interpolation
|
176
|
+
if StepTypeResolver.glob_step?(step, context)
|
177
|
+
return execute_glob_step(step)
|
178
|
+
end
|
179
|
+
|
180
|
+
interpolated_step = interpolator.interpolate(step)
|
181
|
+
|
182
|
+
if StepTypeResolver.command_step?(interpolated_step)
|
183
|
+
# Command step - execute directly, preserving any passed options
|
184
|
+
exit_on_error = options.fetch(:exit_on_error, true)
|
185
|
+
execute_command_step(interpolated_step, { exit_on_error: exit_on_error })
|
186
|
+
else
|
187
|
+
exit_on_error = options.fetch(:exit_on_error, context.exit_on_error?(step))
|
188
|
+
execute_standard_step(interpolated_step, { exit_on_error: exit_on_error })
|
189
|
+
end
|
190
|
+
end
|
191
|
+
|
192
|
+
def execute_standard_step(step, options)
|
193
|
+
exit_on_error = options.fetch(:exit_on_error, true)
|
194
|
+
step_orchestrator.execute_step(step, exit_on_error: exit_on_error)
|
195
|
+
end
|
196
|
+
|
197
|
+
def validate_each_step!(step)
|
198
|
+
unless step.key?("as") && step.key?("steps")
|
199
|
+
raise WorkflowExecutor::ConfigurationError,
|
200
|
+
"Invalid 'each' step format. 'as' and 'steps' must be at the same level as 'each'"
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
204
|
+
end
|
205
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/workflow/step_executor_registry"
|
4
|
+
require "roast/workflow/step_executors/hash_step_executor"
|
5
|
+
require "roast/workflow/step_executors/parallel_step_executor"
|
6
|
+
require "roast/workflow/step_executors/string_step_executor"
|
7
|
+
|
8
|
+
module Roast
|
9
|
+
module Workflow
|
10
|
+
# Factory for creating step executors - now delegates to registry
|
11
|
+
class StepExecutorFactory
|
12
|
+
class << self
|
13
|
+
# Method to ensure default executors are registered
|
14
|
+
def ensure_defaults_registered
|
15
|
+
return if @defaults_registered
|
16
|
+
|
17
|
+
StepExecutorRegistry.register(Hash, StepExecutors::HashStepExecutor)
|
18
|
+
StepExecutorRegistry.register(Array, StepExecutors::ParallelStepExecutor)
|
19
|
+
StepExecutorRegistry.register(String, StepExecutors::StringStepExecutor)
|
20
|
+
|
21
|
+
@defaults_registered = true
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
# Initialize on first use
|
26
|
+
ensure_defaults_registered
|
27
|
+
|
28
|
+
class << self
|
29
|
+
# Delegate to the registry for backward compatibility
|
30
|
+
def for(step, workflow_executor)
|
31
|
+
ensure_defaults_registered
|
32
|
+
StepExecutorRegistry.for(step, workflow_executor)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Allow registration of new executors
|
36
|
+
def register(klass, executor_class)
|
37
|
+
StepExecutorRegistry.register(klass, executor_class)
|
38
|
+
end
|
39
|
+
|
40
|
+
# Allow registration with custom matchers
|
41
|
+
def register_with_matcher(matcher, executor_class)
|
42
|
+
StepExecutorRegistry.register_with_matcher(matcher, executor_class)
|
43
|
+
end
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
# Registry pattern for step executors - eliminates case statements
|
6
|
+
# and follows Open/Closed Principle
|
7
|
+
class StepExecutorRegistry
|
8
|
+
class UnknownStepTypeError < StandardError; end
|
9
|
+
|
10
|
+
@executors = {}
|
11
|
+
@type_matchers = []
|
12
|
+
|
13
|
+
class << self
|
14
|
+
# Register an executor for a specific class
|
15
|
+
# @param klass [Class] The class to match
|
16
|
+
# @param executor_class [Class] The executor class to use
|
17
|
+
def register(klass, executor_class)
|
18
|
+
@executors[klass] = executor_class
|
19
|
+
end
|
20
|
+
|
21
|
+
# Register an executor with a custom matcher
|
22
|
+
# @param matcher [Proc] A proc that returns true if the step matches
|
23
|
+
# @param executor_class [Class] The executor class to use
|
24
|
+
def register_with_matcher(matcher, executor_class)
|
25
|
+
@type_matchers << { matcher: matcher, executor_class: executor_class }
|
26
|
+
end
|
27
|
+
|
28
|
+
# Find the appropriate executor for a step
|
29
|
+
# @param step [Object] The step to find an executor for
|
30
|
+
# @param workflow_executor [WorkflowExecutor] The workflow executor instance
|
31
|
+
# @return [Object] An instance of the appropriate executor
|
32
|
+
def for(step, workflow_executor)
|
33
|
+
executor_class = find_executor_class(step)
|
34
|
+
|
35
|
+
unless executor_class
|
36
|
+
raise UnknownStepTypeError, "No executor registered for step type: #{step.class} (#{step.inspect})"
|
37
|
+
end
|
38
|
+
|
39
|
+
executor_class.new(workflow_executor)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Clear all registrations (useful for testing)
|
43
|
+
def clear!
|
44
|
+
@executors.clear
|
45
|
+
@type_matchers.clear
|
46
|
+
# Reset the factory's defaults flag if it's defined
|
47
|
+
if defined?(StepExecutorFactory)
|
48
|
+
StepExecutorFactory.instance_variable_set(:@defaults_registered, false)
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Get all registered executors (useful for debugging)
|
53
|
+
def registered_executors
|
54
|
+
@executors.dup
|
55
|
+
end
|
56
|
+
|
57
|
+
private
|
58
|
+
|
59
|
+
def find_executor_class(step)
|
60
|
+
# First check exact class matches
|
61
|
+
executor_class = @executors[step.class]
|
62
|
+
return executor_class if executor_class
|
63
|
+
|
64
|
+
# Then check custom matchers
|
65
|
+
matcher_entry = @type_matchers.find { |entry| entry[:matcher].call(step) }
|
66
|
+
return matcher_entry[:executor_class] if matcher_entry
|
67
|
+
|
68
|
+
# Finally check inheritance chain
|
69
|
+
step.class.ancestors.each do |ancestor|
|
70
|
+
executor_class = @executors[ancestor]
|
71
|
+
return executor_class if executor_class
|
72
|
+
end
|
73
|
+
|
74
|
+
nil
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
module StepExecutors
|
6
|
+
class BaseStepExecutor
|
7
|
+
def initialize(workflow_executor)
|
8
|
+
@workflow_executor = workflow_executor
|
9
|
+
@workflow = workflow_executor.workflow
|
10
|
+
@config_hash = workflow_executor.config_hash
|
11
|
+
end
|
12
|
+
|
13
|
+
def execute(step)
|
14
|
+
raise NotImplementedError, "Subclasses must implement execute"
|
15
|
+
end
|
16
|
+
|
17
|
+
protected
|
18
|
+
|
19
|
+
attr_reader :workflow_executor, :workflow, :config_hash
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,43 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/workflow/step_executors/base_step_executor"
|
4
|
+
require "roast/workflow/step_runner"
|
5
|
+
|
6
|
+
module Roast
|
7
|
+
module Workflow
|
8
|
+
module StepExecutors
|
9
|
+
class HashStepExecutor < BaseStepExecutor
|
10
|
+
def execute(step)
|
11
|
+
# execute a command and store the output in a variable
|
12
|
+
name, command = step.to_a.flatten
|
13
|
+
|
14
|
+
# Interpolate variable name if it contains {{}}
|
15
|
+
interpolated_name = workflow_executor.interpolate(name)
|
16
|
+
|
17
|
+
if command.is_a?(Hash)
|
18
|
+
step_runner.execute_steps([command])
|
19
|
+
else
|
20
|
+
# Interpolate command value
|
21
|
+
interpolated_command = workflow_executor.interpolate(command)
|
22
|
+
|
23
|
+
# Check if this step has exit_on_error configuration
|
24
|
+
step_config = config_hash[interpolated_name]
|
25
|
+
exit_on_error = step_config.is_a?(Hash) ? step_config.fetch("exit_on_error", true) : true
|
26
|
+
|
27
|
+
workflow.output[interpolated_name] = step_runner.execute_step(interpolated_command, exit_on_error: exit_on_error)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def step_runner
|
34
|
+
@step_runner ||= StepRunner.new(coordinator)
|
35
|
+
end
|
36
|
+
|
37
|
+
def coordinator
|
38
|
+
workflow_executor.step_executor_coordinator
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/workflow/step_executors/base_step_executor"
|
4
|
+
require "roast/workflow/step_runner"
|
5
|
+
|
6
|
+
module Roast
|
7
|
+
module Workflow
|
8
|
+
module StepExecutors
|
9
|
+
class ParallelStepExecutor < BaseStepExecutor
|
10
|
+
def execute(steps)
|
11
|
+
# run steps in parallel, don't proceed until all are done
|
12
|
+
threads = steps.map do |sub_step|
|
13
|
+
Thread.new do
|
14
|
+
# Each thread needs its own isolated execution context
|
15
|
+
Thread.current[:step] = sub_step
|
16
|
+
Thread.current[:result] = nil
|
17
|
+
Thread.current[:error] = nil
|
18
|
+
|
19
|
+
begin
|
20
|
+
# Execute the single step in this thread
|
21
|
+
step_runner.execute_steps([sub_step])
|
22
|
+
Thread.current[:result] = :success
|
23
|
+
rescue => e
|
24
|
+
Thread.current[:error] = e
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
# Wait for all threads to complete
|
30
|
+
threads.each(&:join)
|
31
|
+
|
32
|
+
# Check for errors in any thread
|
33
|
+
threads.each_with_index do |thread, _index|
|
34
|
+
if thread[:error]
|
35
|
+
raise thread[:error]
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
:success
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def step_runner
|
45
|
+
@step_runner ||= StepRunner.new(coordinator)
|
46
|
+
end
|
47
|
+
|
48
|
+
def coordinator
|
49
|
+
workflow_executor.step_executor_coordinator
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/workflow/step_executors/base_step_executor"
|
4
|
+
|
5
|
+
module Roast
|
6
|
+
module Workflow
|
7
|
+
module StepExecutors
|
8
|
+
class StringStepExecutor < BaseStepExecutor
|
9
|
+
def execute(step)
|
10
|
+
# Interpolate any {{}} expressions before executing the step
|
11
|
+
interpolated_step = workflow_executor.interpolate(step)
|
12
|
+
|
13
|
+
# For command steps, check if there's an exit_on_error configuration
|
14
|
+
# We need to extract the step name to look up configuration
|
15
|
+
if interpolated_step.starts_with?("$(")
|
16
|
+
# This is a direct command without a name, so exit_on_error defaults to true
|
17
|
+
workflow_executor.execute_step(interpolated_step)
|
18
|
+
else
|
19
|
+
# Check if this step has exit_on_error configuration
|
20
|
+
step_config = config_hash[step]
|
21
|
+
exit_on_error = step_config.is_a?(Hash) ? step_config.fetch("exit_on_error", true) : true
|
22
|
+
|
23
|
+
workflow_executor.execute_step(interpolated_step, exit_on_error: exit_on_error)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|