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
@@ -1,12 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "workflow_executor"
4
- require_relative "configuration"
5
- require_relative "../helpers/function_caching_interceptor"
3
+ require "roast/workflow/configuration"
4
+ require "roast/workflow/workflow_initializer"
5
+ require "roast/workflow/workflow_runner"
6
6
  require "active_support"
7
7
  require "active_support/isolated_execution_state"
8
8
  require "active_support/notifications"
9
- require "raix"
10
9
 
11
10
  module Roast
12
11
  module Workflow
@@ -21,10 +20,12 @@ module Roast
21
20
  @configuration = Configuration.new(workflow_path, options)
22
21
  @options = options
23
22
  @files = files
24
- @replay_processed = false # Initialize replay tracking
25
- include_tools
26
- load_roast_initializers
27
- configure_api_client
23
+
24
+ # Initialize workflow dependencies
25
+ initializer = WorkflowInitializer.new(@configuration)
26
+ initializer.setup
27
+
28
+ @workflow_runner = WorkflowRunner.new(@configuration, @options)
28
29
  end
29
30
 
30
31
  def begin!
@@ -33,33 +34,18 @@ module Roast
33
34
  $stderr.puts "Workflow: #{configuration.workflow_path}"
34
35
  $stderr.puts "Options: #{options}"
35
36
 
36
- name = configuration.basename
37
- context_path = configuration.context_path
38
-
39
37
  ActiveSupport::Notifications.instrument("roast.workflow.start", {
40
38
  workflow_path: configuration.workflow_path,
41
39
  options: options,
42
- name: name,
40
+ name: configuration.basename,
43
41
  })
44
42
 
45
43
  if files.any?
46
- $stderr.puts "WARNING: Ignoring target parameter because files were provided: #{configuration.target}" if configuration.has_target?
47
- files.each do |file|
48
- $stderr.puts "Running workflow for file: #{file}"
49
- setup_workflow(file.strip, name:, context_path:)
50
- parse(configuration.steps)
51
- end
44
+ @workflow_runner.run_for_files(files)
52
45
  elsif configuration.has_target?
53
- configuration.target.lines.each do |file|
54
- $stderr.puts "Running workflow for file: #{file.strip}"
55
- setup_workflow(file.strip, name:, context_path:)
56
- parse(configuration.steps)
57
- end
46
+ @workflow_runner.run_for_targets
58
47
  else
59
- # Handle targetless workflow - run once without a specific target
60
- $stderr.puts "Running targetless workflow"
61
- setup_workflow(nil, name:, context_path:)
62
- parse(configuration.steps)
48
+ @workflow_runner.run_targetless
63
49
  end
64
50
  ensure
65
51
  execution_time = Time.now - start_time
@@ -70,227 +56,6 @@ module Roast
70
56
  execution_time: execution_time,
71
57
  })
72
58
  end
73
-
74
- private
75
-
76
- def setup_workflow(file, name:, context_path:)
77
- session_name = configuration.name
78
-
79
- @current_workflow = BaseWorkflow.new(
80
- file,
81
- name: name,
82
- context_path: context_path,
83
- resource: configuration.resource,
84
- session_name: session_name,
85
- configuration: configuration,
86
- ).tap do |workflow|
87
- workflow.output_file = options[:output] if options[:output].present?
88
- workflow.verbose = options[:verbose] if options[:verbose].present?
89
- workflow.concise = options[:concise] if options[:concise].present?
90
- end
91
- end
92
-
93
- def include_tools
94
- return unless configuration.tools.present?
95
-
96
- BaseWorkflow.include(Raix::FunctionDispatch)
97
- BaseWorkflow.include(Roast::Helpers::FunctionCachingInterceptor) # Add caching support
98
- BaseWorkflow.include(*configuration.tools.map(&:constantize))
99
- end
100
-
101
- def load_roast_initializers
102
- Roast::Initializers.load_all
103
- end
104
-
105
- def configure_api_client
106
- return unless configuration.api_token
107
-
108
- begin
109
- case configuration.api_provider
110
- when :openrouter
111
- $stderr.puts "Configuring OpenRouter client with token from workflow"
112
- require "open_router"
113
-
114
- Raix.configure do |config|
115
- config.openrouter_client = OpenRouter::Client.new(api_key: configuration.api_token)
116
- end
117
- else
118
- $stderr.puts "Configuring OpenAI client with token from workflow"
119
- require "openai"
120
-
121
- Raix.configure do |config|
122
- config.openai_client = OpenAI::Client.new(access_token: configuration.api_token)
123
- end
124
- end
125
- rescue => e
126
- Roast::Helpers::Logger.error("Error configuring API client: #{e.message}")
127
- # Don't fail the workflow if client can't be configured
128
- end
129
- end
130
-
131
- def load_state_and_update_steps(steps, skip_until, step_name, timestamp)
132
- state_repository = FileStateRepository.new
133
- state_data = nil
134
-
135
- if timestamp
136
- $stderr.puts "Looking for state before '#{step_name}' in session #{timestamp}..."
137
- state_data = state_repository.load_state_before_step(current_workflow, step_name, timestamp: timestamp)
138
- if state_data
139
- $stderr.puts "Successfully loaded state with data from previous step"
140
- restore_workflow_state(state_data)
141
- else
142
- $stderr.puts "Could not find suitable state data from a previous step to '#{step_name}' in session #{timestamp}."
143
- $stderr.puts "Will run workflow from '#{step_name}' without prior context."
144
- end
145
- else
146
- $stderr.puts "Looking for state before '#{step_name}' in most recent session..."
147
- state_data = state_repository.load_state_before_step(current_workflow, step_name)
148
- if state_data
149
- $stderr.puts "Successfully loaded state with data from previous step"
150
- restore_workflow_state(state_data)
151
- else
152
- $stderr.puts "Could not find suitable state data from a previous step to '#{step_name}'."
153
- $stderr.puts "Will run workflow from '#{step_name}' without prior context."
154
- end
155
- end
156
-
157
- # Always return steps from the requested index, regardless of state loading success
158
- steps[skip_until..-1]
159
- end
160
-
161
- # Restore workflow state from loaded state data
162
- def restore_workflow_state(state_data)
163
- return unless state_data && current_workflow
164
-
165
- # Restore output
166
- if state_data[:output] && current_workflow.respond_to?(:output=)
167
- # Use the setter which will ensure it's a HashWithIndifferentAccess
168
- current_workflow.output = state_data[:output]
169
- end
170
-
171
- # Restore transcript if available
172
- if state_data[:transcript] && current_workflow.respond_to?(:transcript=)
173
- current_workflow.transcript = state_data[:transcript]
174
- elsif state_data[:transcript] && current_workflow.respond_to?(:transcript) &&
175
- current_workflow.transcript.respond_to?(:clear) &&
176
- current_workflow.transcript.respond_to?(:<<)
177
- current_workflow.transcript.clear
178
- state_data[:transcript].each do |message|
179
- current_workflow.transcript << message
180
- end
181
- end
182
-
183
- # Restore final output if available
184
- if state_data[:final_output]
185
- # Make sure final_output is always handled as an array
186
- final_output = state_data[:final_output]
187
- final_output = [final_output] if final_output.is_a?(String)
188
-
189
- if current_workflow.respond_to?(:final_output=)
190
- current_workflow.final_output = final_output
191
- elsif current_workflow.instance_variable_defined?(:@final_output)
192
- current_workflow.instance_variable_set(:@final_output, final_output)
193
- end
194
- end
195
- end
196
-
197
- def parse(steps)
198
- return run(steps) if steps.is_a?(String)
199
-
200
- # Handle replay option - skip to the specified step
201
- if @options[:replay] && !@replay_processed
202
- replay_param = @options[:replay]
203
- timestamp = nil
204
- step_name = replay_param
205
-
206
- # Check if timestamp is prepended (format: timestamp:step_name)
207
- if replay_param.include?(":")
208
- timestamp, step_name = replay_param.split(":", 2)
209
-
210
- # Validate timestamp format (YYYYMMDD_HHMMSS_LLL)
211
- unless timestamp.match?(/^\d{8}_\d{6}_\d{3}$/)
212
- raise ArgumentError, "Invalid timestamp format: #{timestamp}. Expected YYYYMMDD_HHMMSS_LLL"
213
- end
214
- end
215
-
216
- # Find step index by iterating through the steps
217
- skip_until = find_step_index_in_array(steps, step_name)
218
-
219
- if skip_until
220
- $stderr.puts "Replaying from step: #{step_name}#{timestamp ? " (session: #{timestamp})" : ""}"
221
- current_workflow.session_timestamp = timestamp if timestamp
222
- steps = load_state_and_update_steps(steps, skip_until, step_name, timestamp)
223
- else
224
- $stderr.puts "Step #{step_name} not found in workflow, running from beginning"
225
- end
226
- @replay_processed = true # Mark that we've processed replay, so we don't do it again in recursive calls
227
- end
228
-
229
- # Use the WorkflowExecutor to execute the steps
230
- executor = WorkflowExecutor.new(current_workflow, configuration.config_hash, configuration.context_path)
231
- executor.execute_steps(steps)
232
-
233
- $stderr.puts "🔥🔥🔥 ROAST COMPLETE! 🔥🔥🔥"
234
-
235
- # Save the final output to the session directory
236
- save_final_output(current_workflow)
237
-
238
- # Save results to file if specified
239
- if current_workflow.output_file
240
- File.write(current_workflow.output_file, current_workflow.final_output)
241
- $stdout.puts "Results saved to #{current_workflow.output_file}"
242
- else
243
- $stdout.puts current_workflow.final_output
244
- end
245
- end
246
-
247
- # Delegates to WorkflowExecutor
248
- def run(name)
249
- executor = WorkflowExecutor.new(current_workflow, configuration.config_hash, configuration.context_path)
250
- executor.execute_step(name)
251
- end
252
-
253
- def find_step_index_in_array(steps_array, step_name)
254
- steps_array.each_with_index do |step, index|
255
- case step
256
- when Hash
257
- # Could be {name: command} or {name: {substeps}}
258
- step_key = step.keys.first
259
- return index if step_key == step_name
260
- when Array
261
- # This is a parallel step container, search inside it
262
- step.each_with_index do |substep, _substep_index|
263
- case substep
264
- when Hash
265
- # Could be {name: command}
266
- substep_key = substep.keys.first
267
- return index if substep_key == step_name
268
- when String
269
- return index if substep == step_name
270
- end
271
- end
272
- when String
273
- return index if step == step_name
274
- end
275
- end
276
- nil
277
- end
278
-
279
- def save_final_output(workflow)
280
- return unless workflow.respond_to?(:session_name) && workflow.session_name && workflow.respond_to?(:final_output)
281
-
282
- begin
283
- final_output = workflow.final_output.to_s
284
- return if final_output.empty?
285
-
286
- state_repository = FileStateRepository.new
287
- output_file = state_repository.save_final_output(workflow, final_output)
288
- $stderr.puts "Final output saved to: #{output_file}" if output_file
289
- rescue => e
290
- # Don't fail if saving output fails
291
- $stderr.puts "Warning: Failed to save final output to session: #{e.message}"
292
- end
293
- end
294
59
  end
295
60
  end
296
61
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Determines the context path for workflow and step classes
6
+ class ContextPathResolver
7
+ class << self
8
+ # Determine the directory where the actual class is defined
9
+ # @param klass [Class] The class to find the context path for
10
+ # @return [String] The directory path containing the class definition
11
+ def resolve(klass)
12
+ # Try to get the file path where the class is defined
13
+ path = if klass.name&.include?("::")
14
+ # For namespaced classes like Roast::Workflow::Grading::Workflow
15
+ # Convert the class name to a relative path
16
+ class_path = klass.name.underscore + ".rb"
17
+ # Look through load path to find the actual file
18
+ $LOAD_PATH.map { |p| File.join(p, class_path) }.find { |f| File.exist?(f) }
19
+ end
20
+
21
+ # Fall back to trying to get the source location
22
+ if path.nil? && klass.instance_methods(false).any?
23
+ # Try to get source location from any instance method
24
+ method = klass.instance_methods(false).first
25
+ source_location = klass.instance_method(method).source_location
26
+ path = source_location&.first
27
+ end
28
+
29
+ # Return directory containing the class definition
30
+ # or the current directory if we can't find it
31
+ File.dirname(path || Dir.pwd)
32
+ end
33
+
34
+ # Resolve context path for an instance
35
+ # @param instance [Object] The instance to find the context path for
36
+ # @return [String] The directory path containing the class definition
37
+ def resolve_for_instance(instance)
38
+ resolve(instance.class)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ class DotAccessHash
6
+ def initialize(hash)
7
+ @hash = hash || {}
8
+ end
9
+
10
+ def [](key)
11
+ value = @hash[key.to_sym] || @hash[key.to_s]
12
+ value.is_a?(Hash) ? DotAccessHash.new(value) : value
13
+ end
14
+
15
+ def []=(key, value)
16
+ @hash[key.to_sym] = value
17
+ end
18
+
19
+ def method_missing(method_name, *args, &block)
20
+ method_str = method_name.to_s
21
+
22
+ # Handle boolean predicate methods (ending with ?)
23
+ if method_str.end_with?("?")
24
+ key = method_str.chomp("?")
25
+ # Always return false for non-existent keys with ? methods
26
+ return false unless has_key?(key) # rubocop:disable Style/PreferredHashMethods
27
+
28
+ !!self[key]
29
+ # Handle setter methods (ending with =)
30
+ elsif method_str.end_with?("=")
31
+ key = method_str.chomp("=")
32
+ self[key] = args.first
33
+ # Handle bang methods (ending with !) - should raise
34
+ elsif method_str.end_with?("!")
35
+ super
36
+ # Handle regular getter methods
37
+ elsif args.empty? && block.nil?
38
+ # Return nil for non-existent keys (like a hash would)
39
+ self[method_str]
40
+ else
41
+ super
42
+ end
43
+ end
44
+
45
+ def respond_to_missing?(method_name, include_private = false)
46
+ method_str = method_name.to_s
47
+
48
+ if method_str.end_with?("!")
49
+ false # Don't respond to bang methods
50
+ elsif method_str.end_with?("?")
51
+ true # Always respond to predicate methods
52
+ elsif method_str.end_with?("=")
53
+ true # Always respond to setter methods
54
+ else
55
+ true # Always respond to getter methods (they return nil if missing)
56
+ end
57
+ end
58
+
59
+ def to_h
60
+ @hash
61
+ end
62
+
63
+ def keys
64
+ @hash.keys
65
+ end
66
+
67
+ def empty?
68
+ @hash.empty?
69
+ end
70
+
71
+ def each(&block)
72
+ @hash.each(&block)
73
+ end
74
+
75
+ def to_s
76
+ @hash.to_s
77
+ end
78
+
79
+ def inspect
80
+ @hash.inspect
81
+ end
82
+
83
+ def to_json(*args)
84
+ @hash.to_json(*args)
85
+ end
86
+
87
+ def merge(other)
88
+ merged_hash = @hash.dup
89
+ other_hash = other.is_a?(DotAccessHash) ? other.to_h : other
90
+ merged_hash.merge!(other_hash)
91
+ DotAccessHash.new(merged_hash)
92
+ end
93
+
94
+ def values
95
+ @hash.values
96
+ end
97
+
98
+ def key?(key)
99
+ has_key?(key) # rubocop:disable Style/PreferredHashMethods
100
+ end
101
+
102
+ def include?(key)
103
+ has_key?(key) # rubocop:disable Style/PreferredHashMethods
104
+ end
105
+
106
+ def fetch(key, *args)
107
+ if has_key?(key) # rubocop:disable Style/PreferredHashMethods
108
+ self[key]
109
+ elsif block_given?
110
+ yield(key)
111
+ elsif !args.empty?
112
+ args[0]
113
+ else
114
+ raise KeyError, "key not found: #{key.inspect}"
115
+ end
116
+ end
117
+
118
+ def dig(*keys)
119
+ keys.inject(self) do |obj, key|
120
+ break nil unless obj.is_a?(DotAccessHash) || obj.is_a?(Hash)
121
+
122
+ if obj.is_a?(DotAccessHash)
123
+ obj[key]
124
+ else
125
+ obj[key.to_sym] || obj[key.to_s]
126
+ end
127
+ end
128
+ end
129
+
130
+ def size
131
+ @hash.size
132
+ end
133
+
134
+ alias_method :length, :size
135
+
136
+ def map(&block)
137
+ @hash.map(&block)
138
+ end
139
+
140
+ def select(&block)
141
+ DotAccessHash.new(@hash.select(&block))
142
+ end
143
+
144
+ def reject(&block)
145
+ DotAccessHash.new(@hash.reject(&block))
146
+ end
147
+
148
+ def compact
149
+ DotAccessHash.new(@hash.compact)
150
+ end
151
+
152
+ def slice(*keys)
153
+ sliced = {}
154
+ keys.each do |key|
155
+ if has_key?(key) # rubocop:disable Style/PreferredHashMethods
156
+ sliced[key.to_sym] = @hash[key.to_sym] || @hash[key.to_s]
157
+ end
158
+ end
159
+ DotAccessHash.new(sliced)
160
+ end
161
+
162
+ def except(*keys)
163
+ excluded = @hash.dup
164
+ keys.each do |key|
165
+ excluded.delete(key.to_sym)
166
+ excluded.delete(key.to_s)
167
+ end
168
+ DotAccessHash.new(excluded)
169
+ end
170
+
171
+ def delete(key)
172
+ @hash.delete(key.to_sym) || @hash.delete(key.to_s)
173
+ end
174
+
175
+ def clear
176
+ @hash.clear
177
+ self
178
+ end
179
+
180
+ def ==(other)
181
+ case other
182
+ when DotAccessHash
183
+ @hash == other.instance_variable_get(:@hash)
184
+ when Hash
185
+ @hash == other
186
+ else
187
+ false
188
+ end
189
+ end
190
+
191
+ def has_key?(key_name)
192
+ @hash.key?(key_name.to_sym) || @hash.key?(key_name.to_s)
193
+ end
194
+
195
+ alias_method :member?, :has_key?
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Executes steps for each item in a collection
6
+ class EachStep < BaseIterationStep
7
+ attr_reader :collection_expr, :variable_name
8
+
9
+ def initialize(workflow, collection_expr:, variable_name:, steps:, **kwargs)
10
+ super(workflow, steps: steps, **kwargs)
11
+ @collection_expr = collection_expr
12
+ @variable_name = variable_name
13
+ end
14
+
15
+ def call
16
+ # Process the collection expression with appropriate type coercion
17
+ collection = process_iteration_input(@collection_expr, workflow, coerce_to: :iterable)
18
+
19
+ unless collection.respond_to?(:each)
20
+ $stderr.puts "Error: Collection '#{@collection_expr}' is not iterable"
21
+ raise ArgumentError, "Collection '#{@collection_expr}' is not iterable"
22
+ end
23
+
24
+ results = []
25
+ $stderr.puts "Starting each loop over collection with #{collection.size} items"
26
+
27
+ # Iterate over the collection
28
+ collection.each_with_index do |item, index|
29
+ $stderr.puts "Each loop iteration #{index + 1} with #{@variable_name}=#{item.inspect}"
30
+
31
+ # Create a context with the current item as a variable
32
+ define_iteration_variable(item)
33
+
34
+ # Execute the nested steps
35
+ step_results = execute_nested_steps(@steps, workflow)
36
+ results << step_results
37
+
38
+ # Save state after each iteration if the workflow supports it
39
+ save_iteration_state(index, item) if workflow.respond_to?(:session_name) && workflow.session_name
40
+ end
41
+
42
+ $stderr.puts "Each loop completed with #{collection.size} iterations"
43
+ results
44
+ end
45
+
46
+ private
47
+
48
+ # Keep for backward compatibility, deprecated
49
+ def resolve_collection
50
+ process_iteration_input(@collection_expr, workflow, coerce_to: :iterable)
51
+ end
52
+
53
+ def define_iteration_variable(value)
54
+ # Set the variable in the workflow's context
55
+ workflow.instance_variable_set("@#{@variable_name}", value)
56
+
57
+ # Define a getter method for the variable
58
+ var_name = @variable_name.to_sym
59
+ workflow.singleton_class.class_eval do
60
+ attr_reader(var_name)
61
+ end
62
+
63
+ # Make the variable accessible in the output hash
64
+ workflow.output[@variable_name] = value if workflow.respond_to?(:output)
65
+ end
66
+
67
+ def save_iteration_state(index, item)
68
+ state_repository = FileStateRepository.new
69
+
70
+ # Save the current iteration state
71
+ state_data = {
72
+ step_name: name,
73
+ iteration_index: index,
74
+ current_item: item,
75
+ output: workflow.respond_to?(:output) ? workflow.output.clone : {},
76
+ transcript: workflow.respond_to?(:transcript) ? workflow.transcript.map(&:itself) : [],
77
+ }
78
+
79
+ state_repository.save_state(workflow, "#{name}_item_#{index}", state_data)
80
+ rescue => e
81
+ # Don't fail the workflow if state saving fails
82
+ $stderr.puts "Warning: Failed to save iteration state: #{e.message}"
83
+ end
84
+ end
85
+ end
86
+ end