roast-ai 0.1.7 → 0.2.1

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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +1 -1
  3. data/CHANGELOG.md +49 -1
  4. data/CLAUDE.md +20 -0
  5. data/CLAUDE_NOTES.md +68 -0
  6. data/Gemfile +1 -0
  7. data/Gemfile.lock +9 -6
  8. data/README.md +159 -26
  9. data/bin/roast +27 -0
  10. data/docs/ITERATION_SYNTAX.md +147 -0
  11. data/examples/case_when/README.md +58 -0
  12. data/examples/case_when/detect_language/prompt.md +16 -0
  13. data/examples/case_when/workflow.yml +58 -0
  14. data/examples/conditional/README.md +161 -0
  15. data/examples/conditional/check_condition/prompt.md +1 -0
  16. data/examples/conditional/simple_workflow.yml +15 -0
  17. data/examples/conditional/workflow.yml +23 -0
  18. data/examples/direct_coerce_syntax/README.md +32 -0
  19. data/examples/direct_coerce_syntax/workflow.yml +36 -0
  20. data/examples/dot_notation/README.md +37 -0
  21. data/examples/dot_notation/workflow.yml +44 -0
  22. data/examples/exit_on_error/README.md +50 -0
  23. data/examples/exit_on_error/analyze_lint_output/prompt.md +9 -0
  24. data/examples/exit_on_error/apply_fixes/prompt.md +2 -0
  25. data/examples/exit_on_error/workflow.yml +19 -0
  26. data/examples/grading/workflow.yml +10 -4
  27. data/examples/iteration/IMPLEMENTATION.md +88 -0
  28. data/examples/iteration/README.md +68 -0
  29. data/examples/iteration/analyze_complexity/prompt.md +22 -0
  30. data/examples/iteration/generate_recommendations/prompt.md +21 -0
  31. data/examples/iteration/generate_report/prompt.md +129 -0
  32. data/examples/iteration/implement_fix/prompt.md +25 -0
  33. data/examples/iteration/prioritize_issues/prompt.md +24 -0
  34. data/examples/iteration/prompts/analyze_file.md +28 -0
  35. data/examples/iteration/prompts/generate_summary.md +24 -0
  36. data/examples/iteration/prompts/update_report.md +29 -0
  37. data/examples/iteration/prompts/write_report.md +22 -0
  38. data/examples/iteration/read_file/prompt.md +9 -0
  39. data/examples/iteration/select_next_issue/prompt.md +25 -0
  40. data/examples/iteration/simple_workflow.md +39 -0
  41. data/examples/iteration/simple_workflow.yml +58 -0
  42. data/examples/iteration/update_fix_count/prompt.md +26 -0
  43. data/examples/iteration/verify_fix/prompt.md +29 -0
  44. data/examples/iteration/workflow.yml +42 -0
  45. data/examples/json_handling/README.md +32 -0
  46. data/examples/json_handling/workflow.yml +52 -0
  47. data/examples/openrouter_example/workflow.yml +2 -2
  48. data/examples/smart_coercion_defaults/README.md +65 -0
  49. data/examples/smart_coercion_defaults/workflow.yml +44 -0
  50. data/examples/step_configuration/README.md +87 -0
  51. data/examples/step_configuration/workflow.yml +60 -0
  52. data/examples/workflow_generator/README.md +27 -0
  53. data/examples/workflow_generator/analyze_user_request/prompt.md +34 -0
  54. data/examples/workflow_generator/create_workflow_files/prompt.md +32 -0
  55. data/examples/workflow_generator/get_user_input/prompt.md +14 -0
  56. data/examples/workflow_generator/info_from_roast.rb +22 -0
  57. data/examples/workflow_generator/workflow.yml +35 -0
  58. data/lib/roast/errors.rb +9 -0
  59. data/lib/roast/factories/api_provider_factory.rb +61 -0
  60. data/lib/roast/helpers/function_caching_interceptor.rb +1 -1
  61. data/lib/roast/helpers/minitest_coverage_runner.rb +1 -1
  62. data/lib/roast/helpers/prompt_loader.rb +50 -1
  63. data/lib/roast/resources/base_resource.rb +7 -0
  64. data/lib/roast/resources.rb +6 -6
  65. data/lib/roast/tools/ask_user.rb +40 -0
  66. data/lib/roast/tools/cmd.rb +1 -1
  67. data/lib/roast/tools/search_file.rb +1 -1
  68. data/lib/roast/tools.rb +11 -1
  69. data/lib/roast/value_objects/api_token.rb +49 -0
  70. data/lib/roast/value_objects/step_name.rb +39 -0
  71. data/lib/roast/value_objects/workflow_path.rb +77 -0
  72. data/lib/roast/value_objects.rb +5 -0
  73. data/lib/roast/version.rb +1 -1
  74. data/lib/roast/workflow/api_configuration.rb +61 -0
  75. data/lib/roast/workflow/base_iteration_step.rb +184 -0
  76. data/lib/roast/workflow/base_step.rb +44 -27
  77. data/lib/roast/workflow/base_workflow.rb +76 -73
  78. data/lib/roast/workflow/case_executor.rb +49 -0
  79. data/lib/roast/workflow/case_step.rb +82 -0
  80. data/lib/roast/workflow/command_executor.rb +88 -0
  81. data/lib/roast/workflow/conditional_executor.rb +50 -0
  82. data/lib/roast/workflow/conditional_step.rb +59 -0
  83. data/lib/roast/workflow/configuration.rb +35 -158
  84. data/lib/roast/workflow/configuration_loader.rb +78 -0
  85. data/lib/roast/workflow/configuration_parser.rb +13 -248
  86. data/lib/roast/workflow/context_path_resolver.rb +43 -0
  87. data/lib/roast/workflow/dot_access_hash.rb +198 -0
  88. data/lib/roast/workflow/each_step.rb +86 -0
  89. data/lib/roast/workflow/error_handler.rb +97 -0
  90. data/lib/roast/workflow/expression_evaluator.rb +78 -0
  91. data/lib/roast/workflow/expression_utils.rb +36 -0
  92. data/lib/roast/workflow/file_state_repository.rb +3 -2
  93. data/lib/roast/workflow/interpolator.rb +34 -0
  94. data/lib/roast/workflow/iteration_executor.rb +103 -0
  95. data/lib/roast/workflow/llm_boolean_coercer.rb +55 -0
  96. data/lib/roast/workflow/output_handler.rb +35 -0
  97. data/lib/roast/workflow/output_manager.rb +77 -0
  98. data/lib/roast/workflow/parallel_executor.rb +49 -0
  99. data/lib/roast/workflow/prompt_step.rb +4 -1
  100. data/lib/roast/workflow/repeat_step.rb +75 -0
  101. data/lib/roast/workflow/replay_handler.rb +123 -0
  102. data/lib/roast/workflow/resource_resolver.rb +77 -0
  103. data/lib/roast/workflow/session_manager.rb +6 -2
  104. data/lib/roast/workflow/state_manager.rb +97 -0
  105. data/lib/roast/workflow/step_executor_coordinator.rb +221 -0
  106. data/lib/roast/workflow/step_executor_factory.rb +47 -0
  107. data/lib/roast/workflow/step_executor_registry.rb +79 -0
  108. data/lib/roast/workflow/step_executors/base_step_executor.rb +23 -0
  109. data/lib/roast/workflow/step_executors/hash_step_executor.rb +43 -0
  110. data/lib/roast/workflow/step_executors/parallel_step_executor.rb +54 -0
  111. data/lib/roast/workflow/step_executors/string_step_executor.rb +29 -0
  112. data/lib/roast/workflow/step_finder.rb +97 -0
  113. data/lib/roast/workflow/step_loader.rb +155 -0
  114. data/lib/roast/workflow/step_orchestrator.rb +45 -0
  115. data/lib/roast/workflow/step_runner.rb +23 -0
  116. data/lib/roast/workflow/step_type_resolver.rb +133 -0
  117. data/lib/roast/workflow/workflow_context.rb +60 -0
  118. data/lib/roast/workflow/workflow_executor.rb +90 -209
  119. data/lib/roast/workflow/workflow_initializer.rb +112 -0
  120. data/lib/roast/workflow/workflow_runner.rb +87 -0
  121. data/lib/roast/workflow.rb +3 -0
  122. data/lib/roast.rb +96 -3
  123. data/roast.gemspec +2 -1
  124. data/schema/workflow.json +112 -0
  125. metadata +112 -4
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/workflow/step_finder"
4
+ require "roast/workflow/file_state_repository"
5
+
6
+ module Roast
7
+ module Workflow
8
+ # Handles replay functionality for workflows
9
+ # Manages skipping to specific steps and loading previous state
10
+ class ReplayHandler
11
+ attr_reader :processed
12
+
13
+ def initialize(workflow, state_repository: nil)
14
+ @workflow = workflow
15
+ @state_repository = state_repository || FileStateRepository.new
16
+ @processed = false
17
+ end
18
+
19
+ def process_replay(steps, replay_option)
20
+ return steps unless replay_option && !@processed
21
+
22
+ timestamp, step_name = parse_replay_option(replay_option)
23
+ skip_index = StepFinder.find_index(steps, step_name)
24
+
25
+ if skip_index
26
+ $stderr.puts "Replaying from step: #{step_name}#{timestamp ? " (session: #{timestamp})" : ""}"
27
+ @workflow.session_timestamp = timestamp if timestamp && @workflow.respond_to?(:session_timestamp=)
28
+ steps = load_state_and_get_remaining_steps(steps, skip_index, step_name, timestamp)
29
+ else
30
+ $stderr.puts "Step #{step_name} not found in workflow, running from beginning"
31
+ end
32
+
33
+ @processed = true
34
+ steps
35
+ end
36
+
37
+ def load_state_and_restore(step_name, timestamp: nil)
38
+ state_data = if timestamp
39
+ $stderr.puts "Looking for state before '#{step_name}' in session #{timestamp}..."
40
+ @state_repository.load_state_before_step(@workflow, step_name, timestamp: timestamp)
41
+ else
42
+ $stderr.puts "Looking for state before '#{step_name}' in most recent session..."
43
+ @state_repository.load_state_before_step(@workflow, step_name)
44
+ end
45
+
46
+ if state_data
47
+ $stderr.puts "Successfully loaded state with data from previous step"
48
+ restore_workflow_state(state_data)
49
+ else
50
+ session_info = timestamp ? " in session #{timestamp}" : ""
51
+ $stderr.puts "Could not find suitable state data from a previous step to '#{step_name}'#{session_info}."
52
+ $stderr.puts "Will run workflow from '#{step_name}' without prior context."
53
+ end
54
+
55
+ state_data
56
+ end
57
+
58
+ private
59
+
60
+ def parse_replay_option(replay_param)
61
+ return [nil, replay_param] unless replay_param.include?(":")
62
+
63
+ timestamp, step_name = replay_param.split(":", 2)
64
+
65
+ # Validate timestamp format (YYYYMMDD_HHMMSS_LLL)
66
+ unless timestamp.match?(/^\d{8}_\d{6}_\d{3}$/)
67
+ raise ArgumentError, "Invalid timestamp format: #{timestamp}. Expected YYYYMMDD_HHMMSS_LLL"
68
+ end
69
+
70
+ [timestamp, step_name]
71
+ end
72
+
73
+ def load_state_and_get_remaining_steps(steps, skip_index, step_name, timestamp)
74
+ load_state_and_restore(step_name, timestamp: timestamp)
75
+ # Always return steps from the requested index, regardless of state loading success
76
+ steps[skip_index..-1]
77
+ end
78
+
79
+ def restore_workflow_state(state_data)
80
+ return unless state_data && @workflow
81
+
82
+ restore_output(state_data)
83
+ restore_transcript(state_data)
84
+ restore_final_output(state_data)
85
+ end
86
+
87
+ def restore_output(state_data)
88
+ return unless state_data.key?(:output)
89
+ return unless @workflow.respond_to?(:output=)
90
+
91
+ @workflow.output = state_data[:output]
92
+ end
93
+
94
+ def restore_transcript(state_data)
95
+ return unless state_data.key?(:transcript)
96
+ return unless @workflow.respond_to?(:transcript)
97
+
98
+ # Transcript is an array from Raix::ChatCompletion
99
+ # We need to clear it and repopulate it
100
+ if @workflow.transcript.respond_to?(:clear) && @workflow.transcript.respond_to?(:<<)
101
+ @workflow.transcript.clear
102
+ state_data[:transcript].each do |message|
103
+ @workflow.transcript << message
104
+ end
105
+ end
106
+ end
107
+
108
+ def restore_final_output(state_data)
109
+ return unless state_data.key?(:final_output)
110
+
111
+ # Make sure final_output is always handled as an array
112
+ final_output = state_data[:final_output]
113
+ final_output = [final_output] if final_output.is_a?(String)
114
+
115
+ if @workflow.respond_to?(:final_output=)
116
+ @workflow.final_output = final_output
117
+ elsif @workflow.instance_variable_defined?(:@final_output)
118
+ @workflow.instance_variable_set(:@final_output, final_output)
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -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
- file_id = Digest::MD5.hexdigest(file_path)
71
- file_basename = File.basename(file_path).parameterize.underscore
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,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/workflow/case_executor"
4
+ require "roast/workflow/conditional_executor"
5
+ require "roast/workflow/step_executor_factory"
6
+ require "roast/workflow/step_type_resolver"
7
+
8
+ module Roast
9
+ module Workflow
10
+ # Coordinates the execution of different types of steps
11
+ #
12
+ # This class is responsible for routing steps to their appropriate executors
13
+ # based on the step type. It acts as a central dispatcher that determines
14
+ # which execution strategy to use for each step.
15
+ #
16
+ # Current Architecture:
17
+ # - WorkflowExecutor.execute_steps still handles basic routing for backward compatibility
18
+ # - This coordinator is used by WorkflowExecutor.execute_step for named steps
19
+ # - Some step types (parallel) use the StepExecutorFactory pattern
20
+ # - Other step types use direct execution methods
21
+ #
22
+ # TODO: Future refactoring should move all execution logic from WorkflowExecutor
23
+ # to this coordinator and use the factory pattern consistently for all step types.
24
+ class StepExecutorCoordinator
25
+ def initialize(context:, dependencies:)
26
+ @context = context
27
+ @dependencies = dependencies
28
+ end
29
+
30
+ # Execute a list of steps
31
+ def execute_steps(workflow_steps)
32
+ workflow_steps.each do |step|
33
+ case step
34
+ when Hash
35
+ execute(step)
36
+ when Array
37
+ execute(step)
38
+ when String
39
+ execute(step)
40
+ # Handle pause after string steps
41
+ if @context.workflow.pause_step_name == step
42
+ Kernel.binding.irb # rubocop:disable Lint/Debugger
43
+ end
44
+ else
45
+ step_orchestrator.execute_step(step)
46
+ end
47
+ end
48
+ end
49
+
50
+ # Execute a single step (alias for compatibility)
51
+ def execute_step(step, options = {})
52
+ execute(step, options)
53
+ end
54
+
55
+ # Execute a step based on its type
56
+ # @param step [String, Hash, Array] The step to execute
57
+ # @param options [Hash] Execution options
58
+ # @return [Object] The result of the step execution
59
+ def execute(step, options = {})
60
+ step_type = StepTypeResolver.resolve(step, @context)
61
+
62
+ case step_type
63
+ when StepTypeResolver::COMMAND_STEP
64
+ # Command steps should also go through interpolation
65
+ execute_string_step(step, options)
66
+ when StepTypeResolver::GLOB_STEP
67
+ execute_glob_step(step)
68
+ when StepTypeResolver::ITERATION_STEP
69
+ execute_iteration_step(step)
70
+ when StepTypeResolver::CONDITIONAL_STEP
71
+ execute_conditional_step(step)
72
+ when StepTypeResolver::CASE_STEP
73
+ execute_case_step(step)
74
+ when StepTypeResolver::HASH_STEP
75
+ execute_hash_step(step)
76
+ when StepTypeResolver::PARALLEL_STEP
77
+ # Use factory for parallel steps
78
+ executor = StepExecutorFactory.for(step, workflow_executor)
79
+ executor.execute(step)
80
+ when StepTypeResolver::STRING_STEP
81
+ execute_string_step(step, options)
82
+ else
83
+ execute_standard_step(step, options)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ attr_reader :context, :dependencies
90
+
91
+ def workflow_executor
92
+ dependencies[:workflow_executor]
93
+ end
94
+
95
+ def interpolator
96
+ dependencies[:interpolator]
97
+ end
98
+
99
+ def command_executor
100
+ dependencies[:command_executor]
101
+ end
102
+
103
+ def iteration_executor
104
+ dependencies[:iteration_executor]
105
+ end
106
+
107
+ def conditional_executor
108
+ dependencies[:conditional_executor]
109
+ end
110
+
111
+ def case_executor
112
+ @case_executor ||= dependencies[:case_executor] || CaseExecutor.new(
113
+ context.workflow,
114
+ context.context_path,
115
+ dependencies[:state_manager] || dependencies[:workflow_executor].state_manager,
116
+ workflow_executor,
117
+ )
118
+ end
119
+
120
+ def step_orchestrator
121
+ dependencies[:step_orchestrator]
122
+ end
123
+
124
+ def error_handler
125
+ dependencies[:error_handler]
126
+ end
127
+
128
+ def execute_command_step(step, options)
129
+ exit_on_error = options.fetch(:exit_on_error, true)
130
+ resource_type = @context.resource_type
131
+
132
+ error_handler.with_error_handling(step, resource_type: resource_type) do
133
+ $stderr.puts "Executing: #{step} (Resource type: #{resource_type || "unknown"})"
134
+
135
+ output = command_executor.execute(step, exit_on_error: exit_on_error)
136
+
137
+ # Add to transcript
138
+ workflow = context.workflow
139
+ workflow.transcript << {
140
+ user: "I just executed the following command: ```\n#{step}\n```\n\nHere is the output:\n\n```\n#{output}\n```",
141
+ }
142
+ workflow.transcript << { assistant: "Noted, thank you." }
143
+
144
+ output
145
+ end
146
+ end
147
+
148
+ def execute_glob_step(step)
149
+ Dir.glob(step).join("\n")
150
+ end
151
+
152
+ def execute_iteration_step(step)
153
+ name = step.keys.first
154
+ command = step[name]
155
+
156
+ case name
157
+ when "repeat"
158
+ iteration_executor.execute_repeat(command)
159
+ when "each"
160
+ validate_each_step!(step)
161
+ iteration_executor.execute_each(step)
162
+ end
163
+ end
164
+
165
+ def execute_conditional_step(step)
166
+ conditional_executor.execute_conditional(step)
167
+ end
168
+
169
+ def execute_case_step(step)
170
+ case_executor.execute_case(step)
171
+ end
172
+
173
+ def execute_hash_step(step)
174
+ name, command = step.to_a.flatten
175
+ interpolated_name = interpolator.interpolate(name)
176
+
177
+ if command.is_a?(Hash)
178
+ execute_steps([command])
179
+ else
180
+ interpolated_command = interpolator.interpolate(command)
181
+ exit_on_error = context.exit_on_error?(interpolated_name)
182
+
183
+ # Execute the command directly using the appropriate executor
184
+ result = execute(interpolated_command, { exit_on_error: exit_on_error })
185
+ context.workflow.output[interpolated_name] = result
186
+ result
187
+ end
188
+ end
189
+
190
+ def execute_string_step(step, options = {})
191
+ # Check for glob before interpolation
192
+ if StepTypeResolver.glob_step?(step, context)
193
+ return execute_glob_step(step)
194
+ end
195
+
196
+ interpolated_step = interpolator.interpolate(step)
197
+
198
+ if StepTypeResolver.command_step?(interpolated_step)
199
+ # Command step - execute directly, preserving any passed options
200
+ exit_on_error = options.fetch(:exit_on_error, true)
201
+ execute_command_step(interpolated_step, { exit_on_error: exit_on_error })
202
+ else
203
+ exit_on_error = options.fetch(:exit_on_error, context.exit_on_error?(step))
204
+ execute_standard_step(interpolated_step, { exit_on_error: exit_on_error })
205
+ end
206
+ end
207
+
208
+ def execute_standard_step(step, options)
209
+ exit_on_error = options.fetch(:exit_on_error, true)
210
+ step_orchestrator.execute_step(step, exit_on_error: exit_on_error)
211
+ end
212
+
213
+ def validate_each_step!(step)
214
+ unless step.key?("as") && step.key?("steps")
215
+ raise WorkflowExecutor::ConfigurationError,
216
+ "Invalid 'each' step format. 'as' and 'steps' must be at the same level as 'each'"
217
+ end
218
+ end
219
+ end
220
+ end
221
+ 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