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
@@ -1,55 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
- require "yaml"
3
+ require "active_support/core_ext/module/delegation"
4
+ require "roast/workflow/api_configuration"
5
+ require "roast/workflow/configuration_loader"
6
+ require "roast/workflow/resource_resolver"
7
+ require "roast/workflow/step_finder"
5
8
 
6
9
  module Roast
7
10
  module Workflow
8
11
  # Encapsulates workflow configuration data and provides structured access
9
12
  # to the configuration settings
10
13
  class Configuration
11
- attr_reader :config_hash, :workflow_path, :name, :steps, :tools, :function_configs, :api_token, :api_provider, :model, :resource
14
+ attr_reader :config_hash, :workflow_path, :name, :steps, :tools, :function_configs, :model, :resource
12
15
  attr_accessor :target
13
16
 
14
- def initialize(workflow_path, options = {})
15
- @workflow_path = workflow_path
16
- @config_hash = YAML.load_file(workflow_path)
17
-
18
- # Extract key configuration values
19
- @name = @config_hash["name"] || File.basename(workflow_path, ".yml")
20
- @steps = @config_hash["steps"] || []
17
+ delegate :api_provider, :openrouter?, :openai?, to: :api_configuration
21
18
 
22
- # Process tools configuration
23
- parse_tools
24
-
25
- # Process function-specific configurations
26
- parse_functions
27
-
28
- # Read the target parameter
29
- @target = options[:target] || @config_hash["target"]
19
+ # Delegate api_token to effective_token for backward compatibility
20
+ def api_token
21
+ @api_configuration.effective_token
22
+ end
30
23
 
31
- # Process the target command if it's a shell command
32
- @target = process_target(@target) if has_target?
24
+ def initialize(workflow_path, options = {})
25
+ @workflow_path = workflow_path
33
26
 
34
- # Create the appropriate resource object for the target
35
- if defined?(Roast::Resources)
36
- @resource = if has_target?
37
- Roast::Resources.for(@target)
38
- else
39
- Roast::Resources::NoneResource.new(nil)
40
- end
41
- end
27
+ # Load configuration using ConfigurationLoader
28
+ @config_hash = ConfigurationLoader.load(workflow_path)
42
29
 
43
- # Process API token if provided
44
- if @config_hash["api_token"]
45
- @api_token = process_shell_command(@config_hash["api_token"])
46
- end
30
+ # Extract basic configuration values
31
+ @name = ConfigurationLoader.extract_name(@config_hash, workflow_path)
32
+ @steps = ConfigurationLoader.extract_steps(@config_hash)
33
+ @tools = ConfigurationLoader.extract_tools(@config_hash)
34
+ @function_configs = ConfigurationLoader.extract_functions(@config_hash)
35
+ @model = ConfigurationLoader.extract_model(@config_hash)
47
36
 
48
- # Determine API provider (defaults to OpenAI if not specified)
49
- @api_provider = determine_api_provider
37
+ # Initialize components
38
+ @api_configuration = ApiConfiguration.new(@config_hash)
39
+ @step_finder = StepFinder.new(@steps)
50
40
 
51
- # Extract default model if provided
52
- @model = @config_hash["model"]
41
+ # Process target and resource
42
+ @target = ConfigurationLoader.extract_target(@config_hash, options)
43
+ process_resource
53
44
  end
54
45
 
55
46
  def context_path
@@ -76,62 +67,10 @@ module Roast
76
67
  # Handle different call patterns for backward compatibility
77
68
  if steps_array.is_a?(String) && target_step.nil?
78
69
  target_step = steps_array
79
- steps_array = steps
80
- elsif steps_array.is_a?(Array) && target_step.is_a?(String)
81
- # This is the normal case - steps_array and target_step are provided
82
- else
83
- # Default to self.steps if just the target_step is provided
84
- steps_array = steps
70
+ steps_array = nil
85
71
  end
86
72
 
87
- # First, try using the new more detailed search
88
- steps_array.each_with_index do |step, index|
89
- case step
90
- when Hash
91
- # Could be {name: command} or {name: {substeps}}
92
- step_key = step.keys.first
93
- return index if step_key == target_step
94
- when Array
95
- # This is a parallel step container, search inside it
96
- found = step.any? do |substep|
97
- case substep
98
- when Hash
99
- substep.keys.first == target_step
100
- when String
101
- substep == target_step
102
- else
103
- false
104
- end
105
- end
106
- return index if found
107
- when String
108
- return index if step == target_step
109
- end
110
- end
111
-
112
- # Fall back to the original method using extract_step_name
113
- steps_array.each_with_index do |step, index|
114
- step_name = extract_step_name(step)
115
- if step_name.is_a?(Array)
116
- # For arrays (parallel steps), check if target is in the array
117
- return index if step_name.flatten.include?(target_step)
118
- elsif step_name == target_step
119
- return index
120
- end
121
- end
122
-
123
- nil
124
- end
125
-
126
- # Returns an array of all tool class names
127
- def parse_tools
128
- # Only support array format: ["Roast::Tools::Grep", "Roast::Tools::ReadFile"]
129
- @tools = @config_hash["tools"] || []
130
- end
131
-
132
- # Parse function-specific configurations
133
- def parse_functions
134
- @function_configs = @config_hash["functions"] || {}
73
+ @step_finder.find_index(target_step, steps_array)
135
74
  end
136
75
 
137
76
  # Get configuration for a specific function
@@ -141,77 +80,15 @@ module Roast
141
80
  @function_configs[function_name.to_s] || {}
142
81
  end
143
82
 
144
- def openrouter?
145
- @api_provider == :openrouter
146
- end
147
-
148
- def openai?
149
- @api_provider == :openai
150
- end
151
-
152
83
  private
153
84
 
154
- def determine_api_provider
155
- return :openai unless @config_hash["api_provider"]
156
-
157
- provider = @config_hash["api_provider"].to_s.downcase
158
-
159
- case provider
160
- when "openai"
161
- :openai
162
- when "openrouter"
163
- :openrouter
164
- else
165
- Roast::Helpers::Logger.warn("Unknown API provider '#{provider}', defaulting to OpenAI")
166
- :openai
167
- end
168
- end
169
-
170
- def process_shell_command(command)
171
- # If it's a bash command with the $(command) syntax
172
- if command =~ /^\$\((.*)\)$/
173
- return Open3.capture2e({}, ::Regexp.last_match(1)).first.strip
174
- end
175
-
176
- # Legacy % prefix for backward compatibility
177
- if command.start_with?("% ")
178
- return Open3.capture2e({}, *command.split(" ")[1..-1]).first.strip
179
- end
180
-
181
- # Not a shell command, return as is
182
- command
183
- end
184
-
185
- def process_target(command)
186
- # Process shell command first
187
- processed = process_shell_command(command)
188
-
189
- # If it's a glob pattern, return the full paths of the files it matches
190
- if processed.include?("*")
191
- matched_files = Dir.glob(processed)
192
- # If no files match, return the pattern itself
193
- return processed if matched_files.empty?
194
-
195
- return matched_files.map { |file| File.expand_path(file) }.join("\n")
196
- end
197
-
198
- # For tests, if the command was already processed as a shell command and is simple,
199
- # don't expand the path to avoid breaking existing tests
200
- return processed if command != processed && !processed.include?("/")
85
+ attr_reader :api_configuration
201
86
 
202
- # assumed to be a direct file path(s)
203
- File.expand_path(processed)
204
- end
205
-
206
- def extract_step_name(step)
207
- case step
208
- when String
209
- step
210
- when Hash
211
- step.keys.first
212
- when Array
213
- # For arrays, we'll need special handling as they contain multiple steps
214
- step.map { |s| extract_step_name(s) }
87
+ def process_resource
88
+ if defined?(Roast::Resources)
89
+ @resource = ResourceResolver.resolve(@target, context_path)
90
+ # Update target with processed value for backward compatibility
91
+ @target = @resource.value if has_target?
215
92
  end
216
93
  end
217
94
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Roast
6
+ module Workflow
7
+ # Handles loading and parsing of workflow configuration files
8
+ class ConfigurationLoader
9
+ class << self
10
+ # Load configuration from a YAML file
11
+ # @param workflow_path [String] Path to the workflow YAML file
12
+ # @return [Hash] The parsed configuration hash
13
+ def load(workflow_path)
14
+ validate_path!(workflow_path)
15
+ config_hash = YAML.load_file(workflow_path)
16
+ validate_config!(config_hash)
17
+ config_hash
18
+ end
19
+
20
+ # Extract the workflow name from config or path
21
+ # @param config_hash [Hash] The configuration hash
22
+ # @param workflow_path [String] Path to the workflow file
23
+ # @return [String] The workflow name
24
+ def extract_name(config_hash, workflow_path)
25
+ config_hash["name"] || File.basename(workflow_path, ".yml")
26
+ end
27
+
28
+ # Extract steps from the configuration
29
+ # @param config_hash [Hash] The configuration hash
30
+ # @return [Array] The steps array or empty array
31
+ def extract_steps(config_hash)
32
+ config_hash["steps"] || []
33
+ end
34
+
35
+ # Extract tools from the configuration
36
+ # @param config_hash [Hash] The configuration hash
37
+ # @return [Array] The tools array or empty array
38
+ def extract_tools(config_hash)
39
+ config_hash["tools"] || []
40
+ end
41
+
42
+ # Extract function configurations
43
+ # @param config_hash [Hash] The configuration hash
44
+ # @return [Hash] The functions configuration or empty hash
45
+ def extract_functions(config_hash)
46
+ config_hash["functions"] || {}
47
+ end
48
+
49
+ # Extract model from the configuration
50
+ # @param config_hash [Hash] The configuration hash
51
+ # @return [String, nil] The model name if specified
52
+ def extract_model(config_hash)
53
+ config_hash["model"]
54
+ end
55
+
56
+ # Extract target from config or options
57
+ # @param config_hash [Hash] The configuration hash
58
+ # @param options [Hash] Runtime options
59
+ # @return [String, nil] The target if specified
60
+ def extract_target(config_hash, options = {})
61
+ options[:target] || config_hash["target"]
62
+ end
63
+
64
+ private
65
+
66
+ def validate_path!(workflow_path)
67
+ raise ArgumentError, "Workflow path cannot be nil" if workflow_path.nil?
68
+ raise ArgumentError, "Workflow file not found: #{workflow_path}" unless File.exist?(workflow_path)
69
+ raise ArgumentError, "Workflow path must be a YAML file" unless workflow_path.end_with?(".yml", ".yaml")
70
+ end
71
+
72
+ def validate_config!(config_hash)
73
+ raise ArgumentError, "Invalid workflow configuration" unless config_hash.is_a?(Hash)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -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