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.
Files changed (109) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +1 -1
  3. data/CHANGELOG.md +40 -1
  4. data/CLAUDE.md +20 -0
  5. data/Gemfile +1 -0
  6. data/Gemfile.lock +9 -6
  7. data/README.md +81 -14
  8. data/bin/roast +27 -0
  9. data/docs/ITERATION_SYNTAX.md +119 -0
  10. data/examples/conditional/README.md +161 -0
  11. data/examples/conditional/check_condition/prompt.md +1 -0
  12. data/examples/conditional/simple_workflow.yml +15 -0
  13. data/examples/conditional/workflow.yml +23 -0
  14. data/examples/dot_notation/README.md +37 -0
  15. data/examples/dot_notation/workflow.yml +44 -0
  16. data/examples/exit_on_error/README.md +50 -0
  17. data/examples/exit_on_error/analyze_lint_output/prompt.md +9 -0
  18. data/examples/exit_on_error/apply_fixes/prompt.md +2 -0
  19. data/examples/exit_on_error/workflow.yml +19 -0
  20. data/examples/grading/workflow.yml +5 -1
  21. data/examples/iteration/IMPLEMENTATION.md +88 -0
  22. data/examples/iteration/README.md +68 -0
  23. data/examples/iteration/analyze_complexity/prompt.md +22 -0
  24. data/examples/iteration/generate_recommendations/prompt.md +21 -0
  25. data/examples/iteration/generate_report/prompt.md +129 -0
  26. data/examples/iteration/implement_fix/prompt.md +25 -0
  27. data/examples/iteration/prioritize_issues/prompt.md +24 -0
  28. data/examples/iteration/prompts/analyze_file.md +28 -0
  29. data/examples/iteration/prompts/generate_summary.md +24 -0
  30. data/examples/iteration/prompts/update_report.md +29 -0
  31. data/examples/iteration/prompts/write_report.md +22 -0
  32. data/examples/iteration/read_file/prompt.md +9 -0
  33. data/examples/iteration/select_next_issue/prompt.md +25 -0
  34. data/examples/iteration/simple_workflow.md +39 -0
  35. data/examples/iteration/simple_workflow.yml +58 -0
  36. data/examples/iteration/update_fix_count/prompt.md +26 -0
  37. data/examples/iteration/verify_fix/prompt.md +29 -0
  38. data/examples/iteration/workflow.yml +42 -0
  39. data/examples/openrouter_example/workflow.yml +2 -2
  40. data/examples/workflow_generator/README.md +27 -0
  41. data/examples/workflow_generator/analyze_user_request/prompt.md +34 -0
  42. data/examples/workflow_generator/create_workflow_files/prompt.md +32 -0
  43. data/examples/workflow_generator/get_user_input/prompt.md +14 -0
  44. data/examples/workflow_generator/info_from_roast.rb +22 -0
  45. data/examples/workflow_generator/workflow.yml +35 -0
  46. data/lib/roast/errors.rb +9 -0
  47. data/lib/roast/factories/api_provider_factory.rb +61 -0
  48. data/lib/roast/helpers/function_caching_interceptor.rb +1 -1
  49. data/lib/roast/helpers/minitest_coverage_runner.rb +1 -1
  50. data/lib/roast/helpers/prompt_loader.rb +50 -1
  51. data/lib/roast/resources/base_resource.rb +7 -0
  52. data/lib/roast/resources.rb +6 -6
  53. data/lib/roast/tools/ask_user.rb +40 -0
  54. data/lib/roast/tools/cmd.rb +1 -1
  55. data/lib/roast/tools/search_file.rb +1 -1
  56. data/lib/roast/tools.rb +11 -1
  57. data/lib/roast/value_objects/api_token.rb +49 -0
  58. data/lib/roast/value_objects/step_name.rb +39 -0
  59. data/lib/roast/value_objects/workflow_path.rb +77 -0
  60. data/lib/roast/value_objects.rb +5 -0
  61. data/lib/roast/version.rb +1 -1
  62. data/lib/roast/workflow/api_configuration.rb +61 -0
  63. data/lib/roast/workflow/base_iteration_step.rb +165 -0
  64. data/lib/roast/workflow/base_step.rb +4 -24
  65. data/lib/roast/workflow/base_workflow.rb +76 -73
  66. data/lib/roast/workflow/command_executor.rb +88 -0
  67. data/lib/roast/workflow/conditional_executor.rb +50 -0
  68. data/lib/roast/workflow/conditional_step.rb +96 -0
  69. data/lib/roast/workflow/configuration.rb +35 -158
  70. data/lib/roast/workflow/configuration_loader.rb +78 -0
  71. data/lib/roast/workflow/configuration_parser.rb +13 -248
  72. data/lib/roast/workflow/context_path_resolver.rb +43 -0
  73. data/lib/roast/workflow/dot_access_hash.rb +198 -0
  74. data/lib/roast/workflow/each_step.rb +86 -0
  75. data/lib/roast/workflow/error_handler.rb +97 -0
  76. data/lib/roast/workflow/expression_utils.rb +36 -0
  77. data/lib/roast/workflow/file_state_repository.rb +3 -2
  78. data/lib/roast/workflow/interpolator.rb +34 -0
  79. data/lib/roast/workflow/iteration_executor.rb +85 -0
  80. data/lib/roast/workflow/llm_boolean_coercer.rb +55 -0
  81. data/lib/roast/workflow/output_handler.rb +35 -0
  82. data/lib/roast/workflow/output_manager.rb +77 -0
  83. data/lib/roast/workflow/parallel_executor.rb +49 -0
  84. data/lib/roast/workflow/repeat_step.rb +75 -0
  85. data/lib/roast/workflow/replay_handler.rb +123 -0
  86. data/lib/roast/workflow/resource_resolver.rb +77 -0
  87. data/lib/roast/workflow/session_manager.rb +6 -2
  88. data/lib/roast/workflow/state_manager.rb +97 -0
  89. data/lib/roast/workflow/step_executor_coordinator.rb +205 -0
  90. data/lib/roast/workflow/step_executor_factory.rb +47 -0
  91. data/lib/roast/workflow/step_executor_registry.rb +79 -0
  92. data/lib/roast/workflow/step_executors/base_step_executor.rb +23 -0
  93. data/lib/roast/workflow/step_executors/hash_step_executor.rb +43 -0
  94. data/lib/roast/workflow/step_executors/parallel_step_executor.rb +54 -0
  95. data/lib/roast/workflow/step_executors/string_step_executor.rb +29 -0
  96. data/lib/roast/workflow/step_finder.rb +97 -0
  97. data/lib/roast/workflow/step_loader.rb +154 -0
  98. data/lib/roast/workflow/step_orchestrator.rb +45 -0
  99. data/lib/roast/workflow/step_runner.rb +23 -0
  100. data/lib/roast/workflow/step_type_resolver.rb +117 -0
  101. data/lib/roast/workflow/workflow_context.rb +60 -0
  102. data/lib/roast/workflow/workflow_executor.rb +90 -209
  103. data/lib/roast/workflow/workflow_initializer.rb +112 -0
  104. data/lib/roast/workflow/workflow_runner.rb +87 -0
  105. data/lib/roast/workflow.rb +3 -0
  106. data/lib/roast.rb +96 -3
  107. data/roast.gemspec +2 -1
  108. data/schema/workflow.json +85 -0
  109. metadata +97 -4
@@ -1,239 +1,120 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "English"
3
4
  require "active_support"
4
5
  require "active_support/isolated_execution_state"
5
6
  require "active_support/notifications"
7
+ require "roast/workflow/command_executor"
8
+ require "roast/workflow/conditional_executor"
9
+ require "roast/workflow/error_handler"
10
+ require "roast/workflow/interpolator"
11
+ require "roast/workflow/iteration_executor"
12
+ require "roast/workflow/parallel_executor"
13
+ require "roast/workflow/state_manager"
14
+ require "roast/workflow/step_executor_factory"
15
+ require "roast/workflow/step_executor_coordinator"
16
+ require "roast/workflow/step_loader"
17
+ require "roast/workflow/step_orchestrator"
18
+ require "roast/workflow/step_type_resolver"
19
+ require "roast/workflow/workflow_context"
6
20
 
7
21
  module Roast
8
22
  module Workflow
9
23
  # Handles the execution of workflow steps, including orchestration and threading
24
+ #
25
+ # This class now delegates all step execution to StepExecutorCoordinator,
26
+ # which handles type resolution and execution for all step types.
27
+ # The circular dependency between executors and workflow has been broken
28
+ # by introducing the StepRunner interface.
10
29
  class WorkflowExecutor
11
- DEFAULT_MODEL = "anthropic:claude-3-7-sonnet"
12
-
13
- attr_reader :workflow, :config_hash, :context_path
14
-
15
- def initialize(workflow, config_hash, context_path)
16
- @workflow = workflow
17
- @config_hash = config_hash
18
- @context_path = context_path
19
- end
20
-
21
- def execute_steps(steps)
22
- steps.each do |step|
23
- case step
24
- when Hash
25
- execute_hash_step(step)
26
- when Array
27
- execute_parallel_steps(step)
28
- when String
29
- execute_string_step(step)
30
- else
31
- raise "Unknown step type: #{step.inspect}"
32
- end
33
- end
34
- end
35
-
36
- # Interpolates {{expression}} in a string with values from the workflow context
37
- def interpolate(text)
38
- return text unless text.is_a?(String) && text.include?("{{") && text.include?("}}")
39
-
40
- # Replace all {{expression}} with their evaluated values
41
- text.gsub(/\{\{([^}]+)\}\}/) do |match|
42
- expression = Regexp.last_match(1).strip
43
- begin
44
- # Evaluate the expression in the workflow's context
45
- workflow.instance_eval(expression).to_s
46
- rescue => e
47
- # If evaluation fails, provide a more detailed error message but preserve the original expression
48
- error_msg = "Error interpolating {{#{expression}}}: #{e.message}. This variable is not defined in the workflow context. Please define it before using it in a step name."
49
- $stderr.puts "ERROR: #{error_msg}"
50
- match # Return the original match to preserve it in the string
51
- end
52
- end
53
- end
54
-
55
- def execute_step(name)
56
- start_time = Time.now
57
- # For tests, make sure that we handle this gracefully
58
- resource_type = workflow.respond_to?(:resource) ? workflow.resource&.type : nil
59
-
60
- ActiveSupport::Notifications.instrument("roast.step.start", {
61
- step_name: name,
62
- resource_type: resource_type,
63
- })
64
-
65
- $stderr.puts "Executing: #{name} (Resource type: #{resource_type || "unknown"})"
66
-
67
- result = if name.starts_with?("$(")
68
- strip_and_execute(name).tap do |output|
69
- # Add the command and output to the transcript for reference in following steps
70
- workflow.transcript << { user: "I just executed the following command: ```\n#{name}\n```\n\nHere is the output:\n\n```\n#{output}\n```" }
71
- workflow.transcript << { assistant: "Noted, thank you." }
72
- end
73
- elsif name.include?("*") && (!workflow.respond_to?(:resource) || !workflow.resource)
74
- # Only use the glob method if we don't have a resource object yet
75
- # This is for backward compatibility
76
- glob(name)
77
- else
78
- step_object = find_and_load_step(name)
79
- step_result = step_object.call
80
- workflow.output[name] = step_result
81
-
82
- # Save state after each step if the workflow supports it
83
- save_state(name, step_result) if workflow.respond_to?(:session_name) && workflow.session_name
84
-
85
- step_result
86
- end
87
-
88
- execution_time = Time.now - start_time
89
-
90
- ActiveSupport::Notifications.instrument("roast.step.complete", {
91
- step_name: name,
92
- resource_type: resource_type,
93
- success: true,
94
- execution_time: execution_time,
95
- result_size: result.to_s.length,
96
- })
97
-
98
- result
99
- rescue => e
100
- execution_time = Time.now - start_time
101
-
102
- ActiveSupport::Notifications.instrument("roast.step.error", {
103
- step_name: name,
104
- resource_type: resource_type,
105
- error: e.class.name,
106
- message: e.message,
107
- execution_time: execution_time,
108
- })
109
- raise
110
- end
111
-
112
- private
113
-
114
- def execute_hash_step(step)
115
- # execute a command and store the output in a variable
116
- name, command = step.to_a.flatten
117
-
118
- # Interpolate variable name if it contains {{}}
119
- interpolated_name = interpolate(name)
120
-
121
- if command.is_a?(Hash)
122
- execute_steps([command])
123
- else
124
- # Interpolate command value
125
- interpolated_command = interpolate(command)
126
- workflow.output[interpolated_name] = execute_step(interpolated_command)
30
+ # Define custom exception classes for specific error scenarios
31
+ class WorkflowExecutorError < StandardError
32
+ attr_reader :step_name, :original_error
33
+
34
+ def initialize(message, step_name: nil, original_error: nil)
35
+ @step_name = step_name
36
+ @original_error = original_error
37
+ super(message)
127
38
  end
128
39
  end
129
40
 
130
- def execute_parallel_steps(steps)
131
- # run steps in parallel, don't proceed until all are done
132
- steps.map do |sub_step|
133
- Thread.new { execute_steps([sub_step]) }
134
- end.each(&:join)
41
+ class StepExecutionError < WorkflowExecutorError; end
42
+ class StepNotFoundError < WorkflowExecutorError; end
43
+ class InterpolationError < WorkflowExecutorError; end
44
+ class StateError < WorkflowExecutorError; end
45
+ class ConfigurationError < WorkflowExecutorError; end
46
+
47
+ attr_reader :context, :step_loader, :state_manager, :step_executor_coordinator
48
+
49
+ delegate :workflow, :config_hash, :context_path, to: :context
50
+
51
+ def initialize(workflow, config_hash, context_path,
52
+ error_handler: nil, step_loader: nil, command_executor: nil,
53
+ interpolator: nil, state_manager: nil, iteration_executor: nil,
54
+ conditional_executor: nil, step_orchestrator: nil, step_executor_coordinator: nil)
55
+ # Create context object to reduce data clump
56
+ @context = WorkflowContext.new(
57
+ workflow: workflow,
58
+ config_hash: config_hash,
59
+ context_path: context_path,
60
+ )
61
+
62
+ # Dependencies with defaults
63
+ @error_handler = error_handler || ErrorHandler.new
64
+ @step_loader = step_loader || StepLoader.new(workflow, config_hash, context_path)
65
+ @command_executor = command_executor || CommandExecutor.new(logger: @error_handler)
66
+ @interpolator = interpolator || Interpolator.new(workflow, logger: @error_handler)
67
+ @state_manager = state_manager || StateManager.new(workflow, logger: @error_handler)
68
+ @iteration_executor = iteration_executor || IterationExecutor.new(workflow, context_path, @state_manager)
69
+ @conditional_executor = conditional_executor || ConditionalExecutor.new(workflow, context_path, @state_manager, self)
70
+ @step_orchestrator = step_orchestrator || StepOrchestrator.new(workflow, @step_loader, @state_manager, @error_handler, self)
71
+
72
+ # Initialize coordinator with dependencies
73
+ @step_executor_coordinator = step_executor_coordinator || StepExecutorCoordinator.new(
74
+ context: @context,
75
+ dependencies: {
76
+ workflow_executor: self,
77
+ interpolator: @interpolator,
78
+ command_executor: @command_executor,
79
+ iteration_executor: @iteration_executor,
80
+ conditional_executor: @conditional_executor,
81
+ step_orchestrator: @step_orchestrator,
82
+ error_handler: @error_handler,
83
+ },
84
+ )
135
85
  end
136
86
 
137
- def execute_string_step(step)
138
- # Interpolate any {{}} expressions before executing the step
139
- interpolated_step = interpolate(step)
140
- execute_step(interpolated_step)
87
+ # Logger interface methods for backward compatibility
88
+ def log_error(message)
89
+ @error_handler.log_error(message)
141
90
  end
142
91
 
143
- def find_and_load_step(step_name)
144
- # First check for a prompt step
145
- if step_name.strip.include?(" ")
146
- return Roast::Workflow::PromptStep.new(workflow, name: step_name, auto_loop: false)
147
- end
148
-
149
- # First check for a ruby file with the step name
150
- rb_file_path = File.join(context_path, "#{step_name}.rb")
151
- if File.file?(rb_file_path)
152
- return load_ruby_step(rb_file_path, step_name)
153
- end
154
-
155
- # Check in shared directory for ruby file
156
- shared_rb_path = File.expand_path(File.join(context_path, "..", "shared", "#{step_name}.rb"))
157
- if File.file?(shared_rb_path)
158
- return load_ruby_step(shared_rb_path, step_name, File.dirname(shared_rb_path))
159
- end
160
-
161
- # Continue with existing directory check logic
162
- step_path = File.join(context_path, step_name)
163
- step_path = File.expand_path(File.join(context_path, "..", "shared", step_name)) unless File.directory?(step_path)
164
- raise "Step directory or file not found: #{step_path}" unless File.directory?(step_path)
165
-
166
- setup_step(Roast::Workflow::BaseStep, step_name, step_path)
92
+ def log_warning(message)
93
+ @error_handler.log_warning(message)
167
94
  end
168
95
 
169
- def glob(name)
170
- Dir.glob(name).join("\n")
96
+ def warn(message)
97
+ @error_handler.log_warning(message)
171
98
  end
172
99
 
173
- def load_ruby_step(file_path, step_name, context_path = File.dirname(file_path))
174
- $stderr.puts "Requiring step file: #{file_path}"
175
- require file_path
176
- step_class = step_name.classify.constantize
177
- setup_step(step_class, step_name, context_path)
100
+ def error(message)
101
+ @error_handler.log_error(message)
178
102
  end
179
103
 
180
- def setup_step(step_class, step_name, context_path)
181
- step_class.new(workflow, name: step_name, context_path: context_path).tap do |step|
182
- step_config = config_hash[step_name]
183
-
184
- # Always set the model, even if there's no step_config
185
- # Use step-specific model if defined, otherwise use workflow default model, or fallback to DEFAULT_MODEL
186
- step.model = step_config&.dig("model") || config_hash["model"] || DEFAULT_MODEL
187
-
188
- # Pass resource to step if supported
189
- step.resource = workflow.resource if step.respond_to?(:resource=)
190
-
191
- if step_config.present?
192
- step.print_response = step_config["print_response"] if step_config["print_response"].present?
193
- step.loop = step_config["loop"] if step_config["loop"].present?
194
- step.json = step_config["json"] if step_config["json"].present?
195
- step.params = step_config["params"] if step_config["params"].present?
196
- end
197
- end
104
+ def execute_steps(workflow_steps)
105
+ @step_executor_coordinator.execute_steps(workflow_steps)
198
106
  end
199
107
 
200
- def strip_and_execute(step)
201
- if step.match?(/^\$\((.*)\)$/)
202
- # Extract the command from the $(command) syntax
203
- command = step.strip.match(/^\$\((.*)\)$/)[1]
204
-
205
- # NOTE: We don't need to call interpolate here as it's already been done
206
- # in execute_string_step before this method is called
207
- %x(#{command})
208
- else
209
- raise "Missing closing parentheses: #{step}"
210
- end
108
+ def interpolate(text)
109
+ @interpolator.interpolate(text)
211
110
  end
212
111
 
213
- def save_state(step_name, step_result)
214
- state_repository = FileStateRepository.new
215
-
216
- # Gather necessary data for state
217
- static_data = workflow.respond_to?(:transcript) ? workflow.transcript.map(&:itself) : []
218
-
219
- # Get output and final_output if available
220
- output = workflow.respond_to?(:output) ? workflow.output.clone : {}
221
- final_output = workflow.respond_to?(:final_output) ? workflow.final_output.clone : []
222
-
223
- state_data = {
224
- step_name: step_name,
225
- order: output.keys.index(step_name) || output.size,
226
- transcript: static_data,
227
- output: output,
228
- final_output: final_output,
229
- execution_order: output.keys,
230
- }
231
-
232
- # Save the state
233
- state_repository.save_state(workflow, step_name, state_data)
234
- rescue => e
235
- # Don't fail the workflow if state saving fails
236
- $stderr.puts "Warning: Failed to save workflow state: #{e.message}"
112
+ def execute_step(name, exit_on_error: true)
113
+ @step_executor_coordinator.execute(name, exit_on_error: exit_on_error)
114
+ rescue StepLoader::StepNotFoundError => e
115
+ raise StepNotFoundError.new(e.message, step_name: e.step_name, original_error: e.original_error)
116
+ rescue StepLoader::StepExecutionError => e
117
+ raise StepExecutionError.new(e.message, step_name: e.step_name, original_error: e.original_error)
237
118
  end
238
119
  end
239
120
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "raix"
4
+ require "roast/initializers"
5
+ require "roast/helpers/function_caching_interceptor"
6
+ require "roast/helpers/logger"
7
+ require "roast/workflow/base_workflow"
8
+
9
+ module Roast
10
+ module Workflow
11
+ # Handles initialization of workflow dependencies: initializers, tools, and API clients
12
+ class WorkflowInitializer
13
+ def initialize(configuration)
14
+ @configuration = configuration
15
+ end
16
+
17
+ def setup
18
+ load_roast_initializers
19
+ include_tools
20
+ configure_api_client
21
+ end
22
+
23
+ private
24
+
25
+ def load_roast_initializers
26
+ Roast::Initializers.load_all
27
+ end
28
+
29
+ def include_tools
30
+ return unless @configuration.tools.present?
31
+
32
+ BaseWorkflow.include(Raix::FunctionDispatch)
33
+ BaseWorkflow.include(Roast::Helpers::FunctionCachingInterceptor) # Add caching support
34
+ BaseWorkflow.include(*@configuration.tools.map(&:constantize))
35
+ end
36
+
37
+ def configure_api_client
38
+ # Skip if api client is already configured (e.g., by initializers)
39
+ return if api_client_already_configured?
40
+
41
+ # Skip if no api_token is provided in the workflow
42
+ return if @configuration.api_token.blank?
43
+
44
+ client = case @configuration.api_provider
45
+ when :openrouter
46
+ configure_openrouter_client
47
+ when :openai
48
+ configure_openai_client
49
+ when nil
50
+ # Skip configuration if no api_provider is set
51
+ return
52
+ else
53
+ raise "Unsupported api_provider in workflow configuration: #{@configuration.api_provider}"
54
+ end
55
+
56
+ # Validate the client configuration by making a test API call
57
+ validate_api_client(client) if client
58
+ rescue OpenRouter::ConfigurationError, Faraday::UnauthorizedError => e
59
+ error = Roast::AuthenticationError.new("API authentication failed: No API token provided or token is invalid")
60
+ error.set_backtrace(e.backtrace)
61
+
62
+ ActiveSupport::Notifications.instrument("roast.workflow.start.error", {
63
+ error: error.class.name,
64
+ message: error.message,
65
+ })
66
+
67
+ raise error
68
+ rescue => e
69
+ Roast::Helpers::Logger.error("Error configuring API client: #{e.message}")
70
+ raise e
71
+ end
72
+
73
+ def api_client_already_configured?
74
+ case @configuration.api_provider
75
+ when :openrouter
76
+ Raix.configuration.openrouter_client.present?
77
+ when :openai
78
+ Raix.configuration.openai_client.present?
79
+ else
80
+ false
81
+ end
82
+ end
83
+
84
+ def configure_openrouter_client
85
+ $stderr.puts "Configuring OpenRouter client with token from workflow"
86
+ require "open_router"
87
+
88
+ client = OpenRouter::Client.new(access_token: @configuration.api_token)
89
+ Raix.configure do |config|
90
+ config.openrouter_client = client
91
+ end
92
+ client
93
+ end
94
+
95
+ def configure_openai_client
96
+ $stderr.puts "Configuring OpenAI client with token from workflow"
97
+ require "openai"
98
+
99
+ client = OpenAI::Client.new(access_token: @configuration.api_token)
100
+ Raix.configure do |config|
101
+ config.openai_client = client
102
+ end
103
+ client
104
+ end
105
+
106
+ def validate_api_client(client)
107
+ # Make a lightweight API call to validate the token
108
+ client.models.list if client.respond_to?(:models)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/notifications"
4
+ require "roast/workflow/replay_handler"
5
+ require "roast/workflow/workflow_executor"
6
+ require "roast/workflow/output_handler"
7
+ require "roast/workflow/base_workflow"
8
+
9
+ module Roast
10
+ module Workflow
11
+ # Handles running workflows for files/targets and orchestrating execution
12
+ class WorkflowRunner
13
+ def initialize(configuration, options = {})
14
+ @configuration = configuration
15
+ @options = options
16
+ @output_handler = OutputHandler.new
17
+ end
18
+
19
+ def run_for_files(files)
20
+ if @configuration.has_target?
21
+ $stderr.puts "WARNING: Ignoring target parameter because files were provided: #{@configuration.target}"
22
+ end
23
+
24
+ files.each do |file|
25
+ $stderr.puts "Running workflow for file: #{file}"
26
+ run_single_workflow(file.strip)
27
+ end
28
+ end
29
+
30
+ def run_for_targets
31
+ @configuration.target.lines.each do |file|
32
+ $stderr.puts "Running workflow for file: #{file.strip}"
33
+ run_single_workflow(file.strip)
34
+ end
35
+ end
36
+
37
+ def run_targetless
38
+ $stderr.puts "Running targetless workflow"
39
+ run_single_workflow(nil)
40
+ end
41
+
42
+ # Public for backward compatibility with tests
43
+ def execute_workflow(workflow)
44
+ steps = @configuration.steps
45
+
46
+ # Handle replay option
47
+ if @options[:replay]
48
+ replay_handler = ReplayHandler.new(workflow)
49
+ steps = replay_handler.process_replay(steps, @options[:replay])
50
+ end
51
+
52
+ # Execute the steps
53
+ executor = WorkflowExecutor.new(workflow, @configuration.config_hash, @configuration.context_path)
54
+ executor.execute_steps(steps)
55
+
56
+ $stderr.puts "🔥🔥🔥 ROAST COMPLETE! 🔥🔥🔥"
57
+
58
+ # Save outputs
59
+ @output_handler.save_final_output(workflow)
60
+ @output_handler.write_results(workflow)
61
+ end
62
+
63
+ private
64
+
65
+ def run_single_workflow(file)
66
+ workflow = create_workflow(file)
67
+ execute_workflow(workflow)
68
+ end
69
+
70
+ def create_workflow(file)
71
+ BaseWorkflow.new(
72
+ file,
73
+ name: @configuration.basename,
74
+ context_path: @configuration.context_path,
75
+ resource: @configuration.resource,
76
+ session_name: @configuration.name,
77
+ configuration: @configuration,
78
+ ).tap do |workflow|
79
+ workflow.output_file = @options[:output] if @options[:output].present?
80
+ workflow.verbose = @options[:verbose] if @options[:verbose].present?
81
+ workflow.concise = @options[:concise] if @options[:concise].present?
82
+ workflow.pause_step_name = @options[:pause] if @options[:pause].present?
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require "roast/workflow/base_step"
4
4
  require "roast/workflow/prompt_step"
5
+ require "roast/workflow/base_iteration_step"
6
+ require "roast/workflow/repeat_step"
7
+ require "roast/workflow/each_step"
5
8
  require "roast/workflow/base_workflow"
6
9
  require "roast/workflow/configuration"
7
10
  require "roast/workflow/workflow_executor"
data/lib/roast.rb CHANGED
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+ require "cli/ui"
3
5
  require "raix"
4
6
  require "thor"
5
- require "roast/version"
6
- require "roast/tools"
7
+ require "roast/errors"
7
8
  require "roast/helpers"
9
+ require "roast/initializers"
8
10
  require "roast/resources"
11
+ require "roast/tools"
12
+ require "roast/version"
9
13
  require "roast/workflow"
10
- require "roast/initializers"
11
14
 
12
15
  module Roast
13
16
  ROOT = File.expand_path("../..", __FILE__)
@@ -19,6 +22,8 @@ module Roast
19
22
  option :verbose, type: :boolean, aliases: "-v", desc: "Show output from all steps as they are executed"
20
23
  option :target, type: :string, aliases: "-t", desc: "Override target files. Can be file path, glob pattern, or $(shell command)"
21
24
  option :replay, type: :string, aliases: "-r", desc: "Resume workflow from a specific step. Format: step_name or session_timestamp:step_name"
25
+ option :pause, type: :string, aliases: "-p", desc: "Pause workflow after a specific step. Format: step_name"
26
+
22
27
  def execute(*paths)
23
28
  raise Thor::Error, "Workflow configuration file is required" if paths.empty?
24
29
 
@@ -34,6 +39,94 @@ module Roast
34
39
  puts "Roast version #{Roast::VERSION}"
35
40
  end
36
41
 
42
+ desc "init", "Initialize a new Roast workflow from an example"
43
+ option :example, type: :string, aliases: "-e", desc: "Name of the example to use directly (skips picker)"
44
+ def init
45
+ if options[:example]
46
+ copy_example(options[:example])
47
+ else
48
+ show_example_picker
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def show_example_picker
55
+ examples = available_examples
56
+
57
+ if examples.empty?
58
+ puts "No examples found!"
59
+ return
60
+ end
61
+
62
+ puts "Select an option:"
63
+ choices = ["Pick from examples", "New from prompt (beta)"]
64
+
65
+ selected = run_picker(choices, "Select initialization method:")
66
+
67
+ case selected
68
+ when "Pick from examples"
69
+ example_choice = run_picker(examples, "Select an example:")
70
+ copy_example(example_choice) if example_choice
71
+ when "New from prompt (beta)"
72
+ create_from_prompt
73
+ end
74
+ end
75
+
76
+ def available_examples
77
+ examples_dir = File.join(Roast::ROOT, "examples")
78
+ return [] unless File.directory?(examples_dir)
79
+
80
+ Dir.entries(examples_dir)
81
+ .select { |entry| File.directory?(File.join(examples_dir, entry)) && entry != "." && entry != ".." }
82
+ .sort
83
+ end
84
+
85
+ def run_picker(options, prompt)
86
+ return if options.empty?
87
+
88
+ ::CLI::UI::Prompt.ask(prompt) do |handler|
89
+ options.each { |option| handler.option(option) { |selection| selection } }
90
+ end
91
+ end
92
+
93
+ def copy_example(example_name)
94
+ examples_dir = File.join(Roast::ROOT, "examples")
95
+ source_path = File.join(examples_dir, example_name)
96
+ target_path = File.join(Dir.pwd, example_name)
97
+
98
+ unless File.directory?(source_path)
99
+ puts "Example '#{example_name}' not found!"
100
+ return
101
+ end
102
+
103
+ if File.exist?(target_path)
104
+ puts "Directory '#{example_name}' already exists in current directory!"
105
+ return
106
+ end
107
+
108
+ FileUtils.cp_r(source_path, target_path)
109
+ puts "Successfully copied example '#{example_name}' to current directory."
110
+ end
111
+
112
+ def create_from_prompt
113
+ puts("Create a new workflow from a description")
114
+ puts
115
+
116
+ # Execute the workflow generator
117
+ generator_path = File.join(Roast::ROOT, "examples", "workflow_generator", "workflow.yml")
118
+
119
+ begin
120
+ # Execute the workflow generator (it will handle user input)
121
+ Roast::Workflow::ConfigurationParser.new(generator_path, [], {}).begin!
122
+
123
+ puts
124
+ puts("Workflow generation complete!")
125
+ rescue => e
126
+ puts("Error generating workflow: #{e.message}")
127
+ end
128
+ end
129
+
37
130
  class << self
38
131
  def exit_on_failure?
39
132
  true
data/roast.gemspec CHANGED
@@ -37,9 +37,10 @@ Gem::Specification.new do |spec|
37
37
  spec.require_paths = ["lib"]
38
38
 
39
39
  spec.add_dependency("activesupport", "~> 8.0")
40
+ spec.add_dependency("cli-ui")
40
41
  spec.add_dependency("diff-lcs", "~> 1.5")
41
42
  spec.add_dependency("faraday-retry")
42
43
  spec.add_dependency("json-schema")
43
- spec.add_dependency("raix", "~> 0.8.4")
44
+ spec.add_dependency("raix", "~> 0.8.6")
44
45
  spec.add_dependency("thor", "~> 1.3")
45
46
  end