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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yaml +1 -1
- data/CHANGELOG.md +49 -1
- data/CLAUDE.md +20 -0
- data/CLAUDE_NOTES.md +68 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +9 -6
- data/README.md +159 -26
- data/bin/roast +27 -0
- data/docs/ITERATION_SYNTAX.md +147 -0
- data/examples/case_when/README.md +58 -0
- data/examples/case_when/detect_language/prompt.md +16 -0
- data/examples/case_when/workflow.yml +58 -0
- data/examples/conditional/README.md +161 -0
- data/examples/conditional/check_condition/prompt.md +1 -0
- data/examples/conditional/simple_workflow.yml +15 -0
- data/examples/conditional/workflow.yml +23 -0
- data/examples/direct_coerce_syntax/README.md +32 -0
- data/examples/direct_coerce_syntax/workflow.yml +36 -0
- data/examples/dot_notation/README.md +37 -0
- data/examples/dot_notation/workflow.yml +44 -0
- data/examples/exit_on_error/README.md +50 -0
- data/examples/exit_on_error/analyze_lint_output/prompt.md +9 -0
- data/examples/exit_on_error/apply_fixes/prompt.md +2 -0
- data/examples/exit_on_error/workflow.yml +19 -0
- data/examples/grading/workflow.yml +10 -4
- data/examples/iteration/IMPLEMENTATION.md +88 -0
- data/examples/iteration/README.md +68 -0
- data/examples/iteration/analyze_complexity/prompt.md +22 -0
- data/examples/iteration/generate_recommendations/prompt.md +21 -0
- data/examples/iteration/generate_report/prompt.md +129 -0
- data/examples/iteration/implement_fix/prompt.md +25 -0
- data/examples/iteration/prioritize_issues/prompt.md +24 -0
- data/examples/iteration/prompts/analyze_file.md +28 -0
- data/examples/iteration/prompts/generate_summary.md +24 -0
- data/examples/iteration/prompts/update_report.md +29 -0
- data/examples/iteration/prompts/write_report.md +22 -0
- data/examples/iteration/read_file/prompt.md +9 -0
- data/examples/iteration/select_next_issue/prompt.md +25 -0
- data/examples/iteration/simple_workflow.md +39 -0
- data/examples/iteration/simple_workflow.yml +58 -0
- data/examples/iteration/update_fix_count/prompt.md +26 -0
- data/examples/iteration/verify_fix/prompt.md +29 -0
- data/examples/iteration/workflow.yml +42 -0
- data/examples/json_handling/README.md +32 -0
- data/examples/json_handling/workflow.yml +52 -0
- data/examples/openrouter_example/workflow.yml +2 -2
- data/examples/smart_coercion_defaults/README.md +65 -0
- data/examples/smart_coercion_defaults/workflow.yml +44 -0
- data/examples/step_configuration/README.md +87 -0
- data/examples/step_configuration/workflow.yml +60 -0
- data/examples/workflow_generator/README.md +27 -0
- data/examples/workflow_generator/analyze_user_request/prompt.md +34 -0
- data/examples/workflow_generator/create_workflow_files/prompt.md +32 -0
- data/examples/workflow_generator/get_user_input/prompt.md +14 -0
- data/examples/workflow_generator/info_from_roast.rb +22 -0
- data/examples/workflow_generator/workflow.yml +35 -0
- data/lib/roast/errors.rb +9 -0
- data/lib/roast/factories/api_provider_factory.rb +61 -0
- data/lib/roast/helpers/function_caching_interceptor.rb +1 -1
- data/lib/roast/helpers/minitest_coverage_runner.rb +1 -1
- data/lib/roast/helpers/prompt_loader.rb +50 -1
- data/lib/roast/resources/base_resource.rb +7 -0
- data/lib/roast/resources.rb +6 -6
- data/lib/roast/tools/ask_user.rb +40 -0
- data/lib/roast/tools/cmd.rb +1 -1
- data/lib/roast/tools/search_file.rb +1 -1
- data/lib/roast/tools.rb +11 -1
- data/lib/roast/value_objects/api_token.rb +49 -0
- data/lib/roast/value_objects/step_name.rb +39 -0
- data/lib/roast/value_objects/workflow_path.rb +77 -0
- data/lib/roast/value_objects.rb +5 -0
- data/lib/roast/version.rb +1 -1
- data/lib/roast/workflow/api_configuration.rb +61 -0
- data/lib/roast/workflow/base_iteration_step.rb +184 -0
- data/lib/roast/workflow/base_step.rb +44 -27
- data/lib/roast/workflow/base_workflow.rb +76 -73
- data/lib/roast/workflow/case_executor.rb +49 -0
- data/lib/roast/workflow/case_step.rb +82 -0
- data/lib/roast/workflow/command_executor.rb +88 -0
- data/lib/roast/workflow/conditional_executor.rb +50 -0
- data/lib/roast/workflow/conditional_step.rb +59 -0
- data/lib/roast/workflow/configuration.rb +35 -158
- data/lib/roast/workflow/configuration_loader.rb +78 -0
- data/lib/roast/workflow/configuration_parser.rb +13 -248
- data/lib/roast/workflow/context_path_resolver.rb +43 -0
- data/lib/roast/workflow/dot_access_hash.rb +198 -0
- data/lib/roast/workflow/each_step.rb +86 -0
- data/lib/roast/workflow/error_handler.rb +97 -0
- data/lib/roast/workflow/expression_evaluator.rb +78 -0
- data/lib/roast/workflow/expression_utils.rb +36 -0
- data/lib/roast/workflow/file_state_repository.rb +3 -2
- data/lib/roast/workflow/interpolator.rb +34 -0
- data/lib/roast/workflow/iteration_executor.rb +103 -0
- data/lib/roast/workflow/llm_boolean_coercer.rb +55 -0
- data/lib/roast/workflow/output_handler.rb +35 -0
- data/lib/roast/workflow/output_manager.rb +77 -0
- data/lib/roast/workflow/parallel_executor.rb +49 -0
- data/lib/roast/workflow/prompt_step.rb +4 -1
- data/lib/roast/workflow/repeat_step.rb +75 -0
- data/lib/roast/workflow/replay_handler.rb +123 -0
- data/lib/roast/workflow/resource_resolver.rb +77 -0
- data/lib/roast/workflow/session_manager.rb +6 -2
- data/lib/roast/workflow/state_manager.rb +97 -0
- data/lib/roast/workflow/step_executor_coordinator.rb +221 -0
- data/lib/roast/workflow/step_executor_factory.rb +47 -0
- data/lib/roast/workflow/step_executor_registry.rb +79 -0
- data/lib/roast/workflow/step_executors/base_step_executor.rb +23 -0
- data/lib/roast/workflow/step_executors/hash_step_executor.rb +43 -0
- data/lib/roast/workflow/step_executors/parallel_step_executor.rb +54 -0
- data/lib/roast/workflow/step_executors/string_step_executor.rb +29 -0
- data/lib/roast/workflow/step_finder.rb +97 -0
- data/lib/roast/workflow/step_loader.rb +155 -0
- data/lib/roast/workflow/step_orchestrator.rb +45 -0
- data/lib/roast/workflow/step_runner.rb +23 -0
- data/lib/roast/workflow/step_type_resolver.rb +133 -0
- data/lib/roast/workflow/workflow_context.rb +60 -0
- data/lib/roast/workflow/workflow_executor.rb +90 -209
- data/lib/roast/workflow/workflow_initializer.rb +112 -0
- data/lib/roast/workflow/workflow_runner.rb +87 -0
- data/lib/roast/workflow.rb +3 -0
- data/lib/roast.rb +96 -3
- data/roast.gemspec +2 -1
- data/schema/workflow.json +112 -0
- metadata +112 -4
@@ -1,12 +1,11 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
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
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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:
|
40
|
+
name: configuration.basename,
|
43
41
|
})
|
44
42
|
|
45
43
|
if files.any?
|
46
|
-
|
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
|
-
|
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
|
-
|
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
|