roast-ai 0.1.6 → 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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +1 -1
  3. data/CHANGELOG.md +48 -0
  4. data/CLAUDE.md +20 -0
  5. data/Gemfile +1 -0
  6. data/Gemfile.lock +11 -6
  7. data/README.md +225 -13
  8. data/bin/roast +27 -0
  9. data/docs/INSTRUMENTATION.md +42 -1
  10. data/docs/ITERATION_SYNTAX.md +119 -0
  11. data/examples/conditional/README.md +161 -0
  12. data/examples/conditional/check_condition/prompt.md +1 -0
  13. data/examples/conditional/simple_workflow.yml +15 -0
  14. data/examples/conditional/workflow.yml +23 -0
  15. data/examples/dot_notation/README.md +37 -0
  16. data/examples/dot_notation/workflow.yml +44 -0
  17. data/examples/exit_on_error/README.md +50 -0
  18. data/examples/exit_on_error/analyze_lint_output/prompt.md +9 -0
  19. data/examples/exit_on_error/apply_fixes/prompt.md +2 -0
  20. data/examples/exit_on_error/workflow.yml +19 -0
  21. data/examples/grading/workflow.yml +5 -1
  22. data/examples/iteration/IMPLEMENTATION.md +88 -0
  23. data/examples/iteration/README.md +68 -0
  24. data/examples/iteration/analyze_complexity/prompt.md +22 -0
  25. data/examples/iteration/generate_recommendations/prompt.md +21 -0
  26. data/examples/iteration/generate_report/prompt.md +129 -0
  27. data/examples/iteration/implement_fix/prompt.md +25 -0
  28. data/examples/iteration/prioritize_issues/prompt.md +24 -0
  29. data/examples/iteration/prompts/analyze_file.md +28 -0
  30. data/examples/iteration/prompts/generate_summary.md +24 -0
  31. data/examples/iteration/prompts/update_report.md +29 -0
  32. data/examples/iteration/prompts/write_report.md +22 -0
  33. data/examples/iteration/read_file/prompt.md +9 -0
  34. data/examples/iteration/select_next_issue/prompt.md +25 -0
  35. data/examples/iteration/simple_workflow.md +39 -0
  36. data/examples/iteration/simple_workflow.yml +58 -0
  37. data/examples/iteration/update_fix_count/prompt.md +26 -0
  38. data/examples/iteration/verify_fix/prompt.md +29 -0
  39. data/examples/iteration/workflow.yml +42 -0
  40. data/examples/openrouter_example/workflow.yml +2 -2
  41. data/examples/workflow_generator/README.md +27 -0
  42. data/examples/workflow_generator/analyze_user_request/prompt.md +34 -0
  43. data/examples/workflow_generator/create_workflow_files/prompt.md +32 -0
  44. data/examples/workflow_generator/get_user_input/prompt.md +14 -0
  45. data/examples/workflow_generator/info_from_roast.rb +22 -0
  46. data/examples/workflow_generator/workflow.yml +35 -0
  47. data/lib/roast/errors.rb +9 -0
  48. data/lib/roast/factories/api_provider_factory.rb +61 -0
  49. data/lib/roast/helpers/function_caching_interceptor.rb +1 -1
  50. data/lib/roast/helpers/minitest_coverage_runner.rb +1 -1
  51. data/lib/roast/helpers/prompt_loader.rb +50 -1
  52. data/lib/roast/resources/base_resource.rb +7 -0
  53. data/lib/roast/resources.rb +6 -6
  54. data/lib/roast/tools/ask_user.rb +40 -0
  55. data/lib/roast/tools/cmd.rb +1 -1
  56. data/lib/roast/tools/search_file.rb +1 -1
  57. data/lib/roast/tools/update_files.rb +413 -0
  58. data/lib/roast/tools.rb +12 -1
  59. data/lib/roast/value_objects/api_token.rb +49 -0
  60. data/lib/roast/value_objects/step_name.rb +39 -0
  61. data/lib/roast/value_objects/workflow_path.rb +77 -0
  62. data/lib/roast/value_objects.rb +5 -0
  63. data/lib/roast/version.rb +1 -1
  64. data/lib/roast/workflow/api_configuration.rb +61 -0
  65. data/lib/roast/workflow/base_iteration_step.rb +165 -0
  66. data/lib/roast/workflow/base_step.rb +4 -24
  67. data/lib/roast/workflow/base_workflow.rb +76 -73
  68. data/lib/roast/workflow/command_executor.rb +88 -0
  69. data/lib/roast/workflow/conditional_executor.rb +50 -0
  70. data/lib/roast/workflow/conditional_step.rb +96 -0
  71. data/lib/roast/workflow/configuration.rb +35 -158
  72. data/lib/roast/workflow/configuration_loader.rb +78 -0
  73. data/lib/roast/workflow/configuration_parser.rb +13 -248
  74. data/lib/roast/workflow/context_path_resolver.rb +43 -0
  75. data/lib/roast/workflow/dot_access_hash.rb +198 -0
  76. data/lib/roast/workflow/each_step.rb +86 -0
  77. data/lib/roast/workflow/error_handler.rb +97 -0
  78. data/lib/roast/workflow/expression_utils.rb +36 -0
  79. data/lib/roast/workflow/file_state_repository.rb +3 -2
  80. data/lib/roast/workflow/interpolator.rb +34 -0
  81. data/lib/roast/workflow/iteration_executor.rb +85 -0
  82. data/lib/roast/workflow/llm_boolean_coercer.rb +55 -0
  83. data/lib/roast/workflow/output_handler.rb +35 -0
  84. data/lib/roast/workflow/output_manager.rb +77 -0
  85. data/lib/roast/workflow/parallel_executor.rb +49 -0
  86. data/lib/roast/workflow/repeat_step.rb +75 -0
  87. data/lib/roast/workflow/replay_handler.rb +123 -0
  88. data/lib/roast/workflow/resource_resolver.rb +77 -0
  89. data/lib/roast/workflow/session_manager.rb +6 -2
  90. data/lib/roast/workflow/state_manager.rb +97 -0
  91. data/lib/roast/workflow/step_executor_coordinator.rb +205 -0
  92. data/lib/roast/workflow/step_executor_factory.rb +47 -0
  93. data/lib/roast/workflow/step_executor_registry.rb +79 -0
  94. data/lib/roast/workflow/step_executors/base_step_executor.rb +23 -0
  95. data/lib/roast/workflow/step_executors/hash_step_executor.rb +43 -0
  96. data/lib/roast/workflow/step_executors/parallel_step_executor.rb +54 -0
  97. data/lib/roast/workflow/step_executors/string_step_executor.rb +29 -0
  98. data/lib/roast/workflow/step_finder.rb +97 -0
  99. data/lib/roast/workflow/step_loader.rb +154 -0
  100. data/lib/roast/workflow/step_orchestrator.rb +45 -0
  101. data/lib/roast/workflow/step_runner.rb +23 -0
  102. data/lib/roast/workflow/step_type_resolver.rb +117 -0
  103. data/lib/roast/workflow/workflow_context.rb +60 -0
  104. data/lib/roast/workflow/workflow_executor.rb +90 -209
  105. data/lib/roast/workflow/workflow_initializer.rb +112 -0
  106. data/lib/roast/workflow/workflow_runner.rb +87 -0
  107. data/lib/roast/workflow.rb +3 -0
  108. data/lib/roast.rb +96 -3
  109. data/roast.gemspec +3 -1
  110. data/schema/workflow.json +85 -0
  111. metadata +112 -4
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/workflow/expression_utils"
4
+ require "roast/workflow/llm_boolean_coercer"
5
+ require "roast/workflow/workflow_executor"
6
+
7
+ module Roast
8
+ module Workflow
9
+ # Base class for iteration steps (RepeatStep and EachStep)
10
+ class BaseIterationStep < BaseStep
11
+ include ExpressionUtils
12
+
13
+ DEFAULT_MAX_ITERATIONS = 100
14
+
15
+ attr_reader :steps
16
+
17
+ def initialize(workflow, steps:, **kwargs)
18
+ super(workflow, **kwargs)
19
+ @steps = steps
20
+ # Don't initialize cmd_tool here - we'll do it lazily when needed
21
+ end
22
+
23
+ protected
24
+
25
+ # Process various types of inputs and convert to appropriate types for iteration
26
+ def process_iteration_input(input, context, coerce_to: nil)
27
+ if input.is_a?(String)
28
+ if ruby_expression?(input)
29
+ process_ruby_expression(input, context, coerce_to)
30
+ elsif bash_command?(input)
31
+ process_bash_command(input, coerce_to)
32
+ else
33
+ process_step_or_prompt(input, context, coerce_to)
34
+ end
35
+ else
36
+ # Non-string inputs are coerced as-is
37
+ coerce_result(input, coerce_to)
38
+ end
39
+ end
40
+
41
+ # Interpolates {{expression}} in a string with values from the workflow context
42
+ def interpolate_expression(text, context)
43
+ return text unless text.is_a?(String) && text.include?("{{") && text.include?("}}")
44
+
45
+ # Replace all {{expression}} with their evaluated values
46
+ text.gsub(/\{\{([^}]+)\}\}/) do |match|
47
+ expression = extract_expression(match)
48
+ begin
49
+ # Evaluate the expression in the workflow's context
50
+ result = context.instance_eval(expression)
51
+ result.inspect # Convert to string representation
52
+ rescue => e
53
+ warn_interpolation_error(expression, e)
54
+ match # Return the original match to preserve it in the string
55
+ end
56
+ end
57
+ end
58
+
59
+ # Execute nested steps
60
+ def execute_nested_steps(steps, context, executor = nil)
61
+ executor ||= WorkflowExecutor.new(context, {}, context_path)
62
+ results = []
63
+
64
+ steps.each do |step|
65
+ result = case step
66
+ when String
67
+ executor.execute_step(step)
68
+ when Hash, Array
69
+ executor.execute_steps([step])
70
+ end
71
+ results << result
72
+ end
73
+
74
+ results
75
+ end
76
+
77
+ private
78
+
79
+ # Process a Ruby expression
80
+ def process_ruby_expression(input, context, coerce_to)
81
+ expression = extract_expression(input)
82
+ result = evaluate_ruby_expression(expression, context)
83
+ coerce_result(result, coerce_to)
84
+ end
85
+
86
+ # Process a Bash command
87
+ def process_bash_command(input, coerce_to)
88
+ command = extract_command(input)
89
+ execute_command(command, coerce_to)
90
+ end
91
+
92
+ # Process a step name or prompt
93
+ def process_step_or_prompt(input, context, coerce_to)
94
+ step_result = execute_step_by_name(input, context)
95
+ coerce_result(step_result, coerce_to)
96
+ end
97
+
98
+ # Execute a Ruby expression in the workflow context
99
+ def evaluate_ruby_expression(expression, context)
100
+ context.instance_eval(expression)
101
+ rescue => e
102
+ warn_expression_error(expression, e)
103
+ nil
104
+ end
105
+
106
+ # Execute a bash command and return its result
107
+ def execute_command(command, coerce_to)
108
+ # Use the Cmd module to execute the command
109
+ result = Roast::Tools::Cmd.call(command)
110
+
111
+ if coerce_to == :boolean
112
+ # For boolean coercion, use exit status (assume success unless error message)
113
+ !result.to_s.start_with?("Error")
114
+ else
115
+ # For other uses, return the output
116
+ result
117
+ end
118
+ end
119
+
120
+ # Execute a step by name and return its result
121
+ def execute_step_by_name(step_name, context)
122
+ # Reuse existing step execution logic
123
+ executor = WorkflowExecutor.new(context, {}, context_path)
124
+ executor.execute_step(step_name)
125
+ end
126
+
127
+ # Coerce results to the appropriate type
128
+ def coerce_result(result, coerce_to)
129
+ return coerce_to_boolean(result) if coerce_to == :boolean
130
+ return coerce_to_iterable(result) if coerce_to == :iterable
131
+ return coerce_to_llm_boolean(result) if coerce_to == :llm_boolean
132
+
133
+ # Default - return as is
134
+ result
135
+ end
136
+
137
+ # Force a value to boolean
138
+ def coerce_to_boolean(result)
139
+ !!result
140
+ end
141
+
142
+ # Ensure a value is iterable
143
+ def coerce_to_iterable(result)
144
+ return result if result.respond_to?(:each)
145
+
146
+ result.to_s.split("\n")
147
+ end
148
+
149
+ # Convert LLM response to boolean
150
+ def coerce_to_llm_boolean(result)
151
+ LlmBooleanCoercer.coerce(result)
152
+ end
153
+
154
+ # Log a warning for expression evaluation errors
155
+ def warn_expression_error(expression, error)
156
+ $stderr.puts "Warning: Error evaluating expression '#{expression}': #{error.message}"
157
+ end
158
+
159
+ # Log a warning for interpolation errors
160
+ def warn_interpolation_error(expression, error)
161
+ $stderr.puts "Warning: Error interpolating {{#{expression}}}: #{error.message}"
162
+ end
163
+ end
164
+ end
165
+ end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "erb"
4
4
  require "forwardable"
5
+ require "roast/workflow/context_path_resolver"
5
6
 
6
7
  module Roast
7
8
  module Workflow
@@ -15,11 +16,11 @@ module Roast
15
16
  def_delegator :workflow, :chat_completion
16
17
  def_delegator :workflow, :transcript
17
18
 
18
- def initialize(workflow, model: "anthropic:claude-3-7-sonnet", name: nil, context_path: nil, auto_loop: true)
19
+ def initialize(workflow, model: "anthropic:claude-opus-4", name: nil, context_path: nil, auto_loop: true)
19
20
  @workflow = workflow
20
21
  @model = model
21
22
  @name = name || self.class.name.underscore.split("/").last
22
- @context_path = context_path || determine_context_path
23
+ @context_path = context_path || ContextPathResolver.resolve(self.class)
23
24
  @print_response = false
24
25
  @auto_loop = auto_loop
25
26
  @json = false
@@ -35,7 +36,7 @@ module Roast
35
36
  protected
36
37
 
37
38
  def chat_completion(print_response: false, auto_loop: true, json: false, params: {})
38
- workflow.chat_completion(openai: model, loop: auto_loop, json:, params:).then do |response|
39
+ workflow.chat_completion(openai: workflow.openai? && model, loop: auto_loop, model: model, json:, params:).then do |response|
39
40
  case response
40
41
  in Array
41
42
  response.map(&:presence).compact.join("\n")
@@ -47,27 +48,6 @@ module Roast
47
48
  end
48
49
  end
49
50
 
50
- # Determine the directory where the actual class is defined, not BaseWorkflow
51
- def determine_context_path
52
- # Get the actual class's source file
53
- klass = self.class
54
-
55
- # Try to get the file path where the class is defined
56
- path = if klass.name.include?("::")
57
- # For namespaced classes like Roast::Workflow::Grading::Workflow
58
- # Convert the class name to a relative path
59
- class_path = klass.name.underscore + ".rb"
60
- # Look through load path to find the actual file
61
- $LOAD_PATH.map { |p| File.join(p, class_path) }.find { |f| File.exist?(f) }
62
- else
63
- # Fall back to the current file if we can't find it
64
- __FILE__
65
- end
66
-
67
- # Return directory containing the class definition
68
- File.dirname(path || __FILE__)
69
- end
70
-
71
51
  def prompt(text)
72
52
  transcript << { user: text }
73
53
  end
@@ -6,125 +6,128 @@ require "active_support"
6
6
  require "active_support/isolated_execution_state"
7
7
  require "active_support/notifications"
8
8
  require "active_support/core_ext/hash/indifferent_access"
9
+ require "roast/workflow/output_manager"
10
+ require "roast/workflow/context_path_resolver"
9
11
 
10
12
  module Roast
11
13
  module Workflow
12
14
  class BaseWorkflow
13
15
  include Raix::ChatCompletion
14
16
 
15
- attr_reader :output
16
17
  attr_accessor :file,
17
18
  :concise,
18
19
  :output_file,
20
+ :pause_step_name,
19
21
  :verbose,
20
22
  :name,
21
23
  :context_path,
22
24
  :resource,
23
25
  :session_name,
24
26
  :session_timestamp,
25
- :configuration
27
+ :configuration,
28
+ :model
29
+
30
+ delegate :api_provider, :openai?, to: :configuration
31
+ delegate :output, :output=, :append_to_final_output, :final_output, to: :output_manager
26
32
 
27
33
  def initialize(file = nil, name: nil, context_path: nil, resource: nil, session_name: nil, configuration: nil)
28
34
  @file = file
29
35
  @name = name || self.class.name.underscore.split("/").last
30
- @context_path = context_path || determine_context_path
31
- @final_output = []
32
- @output = ActiveSupport::HashWithIndifferentAccess.new
36
+ @context_path = context_path || ContextPathResolver.resolve(self.class)
33
37
  @resource = resource || Roast::Resources.for(file)
34
38
  @session_name = session_name || @name
35
39
  @session_timestamp = nil
36
40
  @configuration = configuration
37
- transcript << { system: read_sidecar_prompt }
38
- Roast::Tools.setup_interrupt_handler(transcript)
39
- Roast::Tools.setup_exit_handler(self)
40
- end
41
-
42
- # Custom writer for output to ensure it's always a HashWithIndifferentAccess
43
- def output=(value)
44
- @output = if value.is_a?(ActiveSupport::HashWithIndifferentAccess)
45
- value
46
- else
47
- ActiveSupport::HashWithIndifferentAccess.new(value)
48
- end
49
- end
50
41
 
51
- def append_to_final_output(message)
52
- @final_output << message
53
- end
42
+ # Initialize managers
43
+ @output_manager = OutputManager.new
54
44
 
55
- def final_output
56
- return @final_output if @final_output.is_a?(String)
57
- return "" if @final_output.nil?
45
+ # Setup prompt and handlers
46
+ read_sidecar_prompt.then do |prompt|
47
+ next unless prompt
58
48
 
59
- # Handle array case (expected normal case)
60
- if @final_output.respond_to?(:join)
61
- @final_output.join("\n\n")
62
- else
63
- # Handle any other unexpected type by converting to string
64
- @final_output.to_s
49
+ transcript << { system: prompt }
65
50
  end
51
+ Roast::Tools.setup_interrupt_handler(transcript)
52
+ Roast::Tools.setup_exit_handler(self)
66
53
  end
67
54
 
68
55
  # Override chat_completion to add instrumentation
69
56
  def chat_completion(**kwargs)
70
57
  start_time = Time.now
71
- model = kwargs[:openai] || "default"
72
-
73
- ActiveSupport::Notifications.instrument("roast.chat_completion.start", {
74
- model: model,
75
- parameters: kwargs.except(:openai),
76
- })
77
-
78
- result = super(**kwargs)
58
+ step_model = kwargs[:model]
59
+
60
+ with_model(step_model) do
61
+ ActiveSupport::Notifications.instrument("roast.chat_completion.start", {
62
+ model: model,
63
+ parameters: kwargs.except(:openai, :model),
64
+ })
65
+
66
+ # Call the parent module's chat_completion
67
+ # skip model because it is read directly from the model method
68
+ result = super(**kwargs.except(:model))
69
+ execution_time = Time.now - start_time
70
+
71
+ ActiveSupport::Notifications.instrument("roast.chat_completion.complete", {
72
+ success: true,
73
+ model: model,
74
+ parameters: kwargs.except(:openai, :model),
75
+ execution_time: execution_time,
76
+ response_size: result.to_s.length,
77
+ })
78
+ result
79
+ end
80
+ rescue Faraday::ResourceNotFound => e
79
81
  execution_time = Time.now - start_time
80
-
81
- ActiveSupport::Notifications.instrument("roast.chat_completion.complete", {
82
- success: true,
83
- model: model,
84
- parameters: kwargs.except(:openai),
85
- execution_time: execution_time,
86
- response_size: result.to_s.length,
87
- })
88
-
89
- result
82
+ message = e.response.dig(:body, "error", "message") || e.message
83
+ error = Roast::ResourceNotFoundError.new(message)
84
+ error.set_backtrace(e.backtrace)
85
+ log_and_raise_error(error, message, step_model || model, kwargs, execution_time)
90
86
  rescue => e
91
87
  execution_time = Time.now - start_time
88
+ log_and_raise_error(e, e.message, step_model || model, kwargs, execution_time)
89
+ end
92
90
 
93
- ActiveSupport::Notifications.instrument("roast.chat_completion.error", {
94
- error: e.class.name,
95
- message: e.message,
96
- model: model,
97
- parameters: kwargs.except(:openai),
98
- execution_time: execution_time,
99
- })
100
- raise
91
+ def with_model(model)
92
+ previous_model = @model
93
+ @model = model
94
+ yield
95
+ ensure
96
+ @model = previous_model
101
97
  end
102
98
 
103
99
  def workflow
104
100
  self
105
101
  end
106
102
 
107
- private
103
+ # Expose output manager for state management
104
+ attr_reader :output_manager
108
105
 
109
- # Determine the directory where the actual class is defined, not BaseWorkflow
110
- def determine_context_path
111
- # Get the actual class's source file
112
- klass = self.class
113
-
114
- # Try to get the file path where the class is defined
115
- path = if klass.name.include?("::")
116
- # For namespaced classes like Roast::Workflow::Grading::Workflow
117
- # Convert the class name to a relative path
118
- class_path = klass.name.underscore + ".rb"
119
- # Look through load path to find the actual file
120
- $LOAD_PATH.map { |p| File.join(p, class_path) }.find { |f| File.exist?(f) }
106
+ # Allow direct access to output values without 'output.' prefix
107
+ def method_missing(method_name, *args, &block)
108
+ if output.respond_to?(method_name)
109
+ output.send(method_name, *args, &block)
121
110
  else
122
- # Fall back to the current file if we can't find it
123
- __FILE__
111
+ super
124
112
  end
113
+ end
114
+
115
+ def respond_to_missing?(method_name, include_private = false)
116
+ output.respond_to?(method_name) || super
117
+ end
118
+
119
+ private
120
+
121
+ def log_and_raise_error(error, message, model, params, execution_time)
122
+ ActiveSupport::Notifications.instrument("roast.chat_completion.error", {
123
+ error: error.class.name,
124
+ message: message,
125
+ model: model,
126
+ parameters: params.except(:openai, :model),
127
+ execution_time: execution_time,
128
+ })
125
129
 
126
- # Return directory containing the class definition
127
- File.dirname(path || __FILE__)
130
+ raise error
128
131
  end
129
132
 
130
133
  def read_sidecar_prompt
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+
5
+ module Roast
6
+ module Workflow
7
+ class CommandExecutor
8
+ class CommandExecutionError < StandardError
9
+ attr_reader :command, :exit_status, :original_error
10
+
11
+ def initialize(message, command:, exit_status: nil, original_error: nil)
12
+ @command = command
13
+ @exit_status = exit_status
14
+ @original_error = original_error
15
+ super(message)
16
+ end
17
+ end
18
+
19
+ def initialize(logger: nil)
20
+ @logger = logger || NullLogger.new
21
+ end
22
+
23
+ def execute(command_string, exit_on_error: true)
24
+ command = extract_command(command_string)
25
+
26
+ output = %x(#{command})
27
+ exit_status = $CHILD_STATUS.exitstatus
28
+
29
+ handle_execution_result(
30
+ command: command,
31
+ output: output,
32
+ exit_status: exit_status,
33
+ success: $CHILD_STATUS.success?,
34
+ exit_on_error: exit_on_error,
35
+ )
36
+ rescue ArgumentError, CommandExecutionError
37
+ raise
38
+ rescue => e
39
+ handle_execution_error(
40
+ command: command,
41
+ error: e,
42
+ exit_on_error: exit_on_error,
43
+ )
44
+ end
45
+
46
+ private
47
+
48
+ def extract_command(command_string)
49
+ match = command_string.strip.match(/^\$\((.*)\)$/)
50
+ raise ArgumentError, "Invalid command format. Expected $(command), got: #{command_string}" unless match
51
+
52
+ match[1]
53
+ end
54
+
55
+ def handle_execution_result(command:, output:, exit_status:, success:, exit_on_error:)
56
+ return output if success
57
+
58
+ if exit_on_error
59
+ raise CommandExecutionError.new(
60
+ "Command exited with non-zero status (#{exit_status})",
61
+ command: command,
62
+ exit_status: exit_status,
63
+ )
64
+ else
65
+ @logger.warn("Command '#{command}' exited with non-zero status (#{exit_status}), continuing execution")
66
+ output + "\n[Exit status: #{exit_status}]"
67
+ end
68
+ end
69
+
70
+ def handle_execution_error(command:, error:, exit_on_error:)
71
+ if exit_on_error
72
+ raise CommandExecutionError.new(
73
+ "Failed to execute command '#{command}': #{error.message}",
74
+ command: command,
75
+ original_error: error,
76
+ )
77
+ else
78
+ @logger.warn("Command '#{command}' failed with error: #{error.message}, continuing execution")
79
+ "Error executing command: #{error.message}\n[Exit status: error]"
80
+ end
81
+ end
82
+
83
+ class NullLogger
84
+ def warn(_message); end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Handles execution of conditional steps (if and unless)
6
+ class ConditionalExecutor
7
+ def initialize(workflow, context_path, state_manager, workflow_executor = nil)
8
+ @workflow = workflow
9
+ @context_path = context_path
10
+ @state_manager = state_manager
11
+ @workflow_executor = workflow_executor
12
+ end
13
+
14
+ def execute_conditional(conditional_config)
15
+ $stderr.puts "Executing conditional step: #{conditional_config.inspect}"
16
+
17
+ # Determine if this is an 'if' or 'unless' condition
18
+ condition_expr = conditional_config["if"] || conditional_config["unless"]
19
+ is_unless = conditional_config.key?("unless")
20
+ then_steps = conditional_config["then"]
21
+
22
+ # Verify required parameters
23
+ raise WorkflowExecutor::ConfigurationError, "Missing condition in conditional configuration" unless condition_expr
24
+ raise WorkflowExecutor::ConfigurationError, "Missing 'then' steps in conditional configuration" unless then_steps
25
+
26
+ # Create and execute a ConditionalStep
27
+ require "roast/workflow/conditional_step" unless defined?(Roast::Workflow::ConditionalStep)
28
+ conditional_step = ConditionalStep.new(
29
+ @workflow,
30
+ config: conditional_config,
31
+ name: "conditional_#{condition_expr.gsub(/[^a-zA-Z0-9_]/, "_")[0..20]}",
32
+ context_path: @context_path,
33
+ workflow_executor: @workflow_executor,
34
+ )
35
+
36
+ result = conditional_step.call
37
+
38
+ # Store a marker in workflow output to indicate which branch was taken
39
+ condition_key = is_unless ? "unless" : "if"
40
+ step_name = "#{condition_key}_#{condition_expr.gsub(/[^a-zA-Z0-9_]/, "_")[0..30]}"
41
+ @workflow.output[step_name] = result
42
+
43
+ # Save state
44
+ @state_manager.save_state(step_name, @workflow.output[step_name])
45
+
46
+ result
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "roast/workflow/base_step"
4
+ require "roast/workflow/command_executor"
5
+ require "roast/workflow/expression_utils"
6
+ require "roast/workflow/interpolator"
7
+
8
+ module Roast
9
+ module Workflow
10
+ class ConditionalStep < BaseStep
11
+ include ExpressionUtils
12
+
13
+ def initialize(workflow, config:, name:, context_path:, workflow_executor:, **kwargs)
14
+ super(workflow, name: name, context_path: context_path, **kwargs)
15
+
16
+ @config = config
17
+ @condition = config["if"] || config["unless"]
18
+ @is_unless = config.key?("unless")
19
+ @then_steps = config["then"] || []
20
+ @else_steps = config["else"] || []
21
+ @workflow_executor = workflow_executor
22
+ end
23
+
24
+ def call
25
+ # Evaluate the condition
26
+ condition_result = evaluate_condition(@condition)
27
+
28
+ # Invert the result if this is an 'unless' condition
29
+ condition_result = !condition_result if @is_unless
30
+
31
+ # Select which steps to execute based on the condition
32
+ steps_to_execute = condition_result ? @then_steps : @else_steps
33
+
34
+ # Execute the selected steps
35
+ unless steps_to_execute.empty?
36
+ @workflow_executor.execute_steps(steps_to_execute)
37
+ end
38
+
39
+ # Return a result indicating which branch was taken
40
+ { condition_result: condition_result, branch_executed: condition_result ? "then" : "else" }
41
+ end
42
+
43
+ private
44
+
45
+ def evaluate_condition(condition)
46
+ return false unless condition.is_a?(String)
47
+
48
+ if ruby_expression?(condition)
49
+ evaluate_ruby_expression(condition)
50
+ elsif bash_command?(condition)
51
+ evaluate_bash_command(condition)
52
+ else
53
+ # Treat as a step name or direct boolean
54
+ evaluate_step_or_value(condition)
55
+ end
56
+ end
57
+
58
+ def evaluate_ruby_expression(expression)
59
+ expr = extract_expression(expression)
60
+ begin
61
+ !!@workflow.instance_eval(expr)
62
+ rescue => e
63
+ $stderr.puts "Warning: Error evaluating expression '#{expr}': #{e.message}"
64
+ false
65
+ end
66
+ end
67
+
68
+ def evaluate_bash_command(command)
69
+ cmd = extract_command(command)
70
+ executor = CommandExecutor.new(logger: Roast::Helpers::Logger)
71
+ begin
72
+ result = executor.execute(cmd, exit_on_error: false)
73
+ # For conditionals, we care about the exit status
74
+ result[:success]
75
+ rescue => e
76
+ $stderr.puts "Warning: Error executing command '#{cmd}': #{e.message}"
77
+ false
78
+ end
79
+ end
80
+
81
+ def evaluate_step_or_value(input)
82
+ # Check if it's a reference to a previous step output
83
+ if @workflow.output.key?(input)
84
+ result = @workflow.output[input]
85
+ # Coerce to boolean
86
+ return false if result.nil? || result == false || result == "" || result == "false"
87
+
88
+ return true
89
+ end
90
+
91
+ # Otherwise treat as a direct value
92
+ input.to_s.downcase == "true"
93
+ end
94
+ end
95
+ end
96
+ end