roast-ai 0.1.0 → 0.1.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/cla.yml +1 -1
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +20 -0
  5. data/CLAUDE.md +3 -1
  6. data/Gemfile +0 -1
  7. data/Gemfile.lock +3 -4
  8. data/README.md +418 -4
  9. data/Rakefile +1 -6
  10. data/docs/INSTRUMENTATION.md +202 -0
  11. data/examples/api_workflow/README.md +85 -0
  12. data/examples/api_workflow/fetch_api_data/prompt.md +10 -0
  13. data/examples/api_workflow/generate_report/prompt.md +10 -0
  14. data/examples/api_workflow/prompt.md +10 -0
  15. data/examples/api_workflow/transform_data/prompt.md +10 -0
  16. data/examples/api_workflow/workflow.yml +30 -0
  17. data/examples/grading/workflow.yml +2 -2
  18. data/examples/instrumentation.rb +76 -0
  19. data/examples/rspec_to_minitest/README.md +68 -0
  20. data/examples/rspec_to_minitest/analyze_spec/prompt.md +30 -0
  21. data/examples/rspec_to_minitest/create_minitest/prompt.md +33 -0
  22. data/examples/rspec_to_minitest/run_and_improve/prompt.md +35 -0
  23. data/examples/rspec_to_minitest/workflow.md +10 -0
  24. data/examples/rspec_to_minitest/workflow.yml +40 -0
  25. data/lib/roast/helpers/function_caching_interceptor.rb +72 -8
  26. data/lib/roast/helpers/prompt_loader.rb +2 -0
  27. data/lib/roast/resources/api_resource.rb +137 -0
  28. data/lib/roast/resources/base_resource.rb +47 -0
  29. data/lib/roast/resources/directory_resource.rb +40 -0
  30. data/lib/roast/resources/file_resource.rb +33 -0
  31. data/lib/roast/resources/none_resource.rb +29 -0
  32. data/lib/roast/resources/url_resource.rb +63 -0
  33. data/lib/roast/resources.rb +100 -0
  34. data/lib/roast/tools/coding_agent.rb +69 -0
  35. data/lib/roast/tools.rb +1 -0
  36. data/lib/roast/version.rb +1 -1
  37. data/lib/roast/workflow/base_step.rb +21 -17
  38. data/lib/roast/workflow/base_workflow.rb +49 -16
  39. data/lib/roast/workflow/configuration.rb +83 -8
  40. data/lib/roast/workflow/configuration_parser.rb +171 -3
  41. data/lib/roast/workflow/file_state_repository.rb +126 -0
  42. data/lib/roast/workflow/prompt_step.rb +16 -0
  43. data/lib/roast/workflow/session_manager.rb +82 -0
  44. data/lib/roast/workflow/state_repository.rb +21 -0
  45. data/lib/roast/workflow/workflow_executor.rb +99 -9
  46. data/lib/roast/workflow.rb +4 -0
  47. data/lib/roast.rb +2 -5
  48. data/roast.gemspec +1 -1
  49. data/schema/workflow.json +12 -0
  50. metadata +31 -6
  51. data/.rspec +0 -1
@@ -8,35 +8,34 @@ module Roast
8
8
  class BaseStep
9
9
  extend Forwardable
10
10
 
11
- attr_accessor :model, :print_response, :loop, :json, :params
11
+ attr_accessor :model, :print_response, :auto_loop, :json, :params, :resource
12
12
  attr_reader :workflow, :name, :context_path
13
13
 
14
14
  def_delegator :workflow, :append_to_final_output
15
15
  def_delegator :workflow, :chat_completion
16
16
  def_delegator :workflow, :transcript
17
17
 
18
- def initialize(workflow, model: "anthropic:claude-3-7-sonnet", name: nil, context_path: nil)
18
+ def initialize(workflow, model: "anthropic:claude-3-7-sonnet", name: nil, context_path: nil, auto_loop: true)
19
19
  @workflow = workflow
20
20
  @model = model
21
21
  @name = name || self.class.name.underscore.split("/").last
22
22
  @context_path = context_path || determine_context_path
23
23
  @print_response = false
24
- @loop = true
24
+ @auto_loop = auto_loop
25
25
  @json = false
26
26
  @params = {}
27
+ @resource = workflow.resource if workflow.respond_to?(:resource)
27
28
  end
28
29
 
29
30
  def call
30
31
  prompt(read_sidecar_prompt)
31
- chat_completion(print_response:, loop:, json:, params:)
32
+ chat_completion(print_response:, auto_loop:, json:, params:)
32
33
  end
33
34
 
34
35
  protected
35
36
 
36
- def chat_completion(print_response: false, loop: true, json: false, params: {})
37
- workflow.chat_completion(openai: model, loop:, json:, params:).tap do |response|
38
- append_to_final_output(response) if print_response
39
- end.then do |response|
37
+ 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|
40
39
  case response
41
40
  in Array
42
41
  response.map(&:presence).compact.join("\n")
@@ -44,7 +43,7 @@ module Roast
44
43
  response
45
44
  end
46
45
  end.tap do |response|
47
- process_sidecar_output(response)
46
+ process_output(response, print_response:)
48
47
  end
49
48
  end
50
49
 
@@ -74,19 +73,24 @@ module Roast
74
73
  end
75
74
 
76
75
  def read_sidecar_prompt
77
- Roast::Helpers::PromptLoader.load_prompt(self, workflow.file)
76
+ # For file resources, use the target path for prompt selection
77
+ # For other resource types, fall back to workflow.file
78
+ target_path = if resource&.type == :file
79
+ resource.target
80
+ else
81
+ workflow.file
82
+ end
83
+
84
+ Roast::Helpers::PromptLoader.load_prompt(self, target_path)
78
85
  end
79
86
 
80
- def process_sidecar_output(response)
81
- # look for a file named output.txt.erb in the context path
82
- # if found, render it with the response
83
- # if not found, just return the response
84
- # TODO: this can be a lot more sophisticated
85
- # incorporating different file types, etc.
87
+ def process_output(response, print_response:)
86
88
  output_path = File.join(context_path, "output.txt")
87
- if File.exist?(output_path)
89
+ if File.exist?(output_path) && print_response
88
90
  # TODO: use the workflow binding or the step?
89
91
  append_to_final_output(ERB.new(File.read(output_path), trim_mode: "-").result(binding))
92
+ elsif print_response
93
+ append_to_final_output(response)
90
94
  end
91
95
  end
92
96
  end
@@ -2,6 +2,9 @@
2
2
 
3
3
  require "raix/chat_completion"
4
4
  require "raix/function_dispatch"
5
+ require "active_support"
6
+ require "active_support/isolated_execution_state"
7
+ require "active_support/notifications"
5
8
 
6
9
  module Roast
7
10
  module Workflow
@@ -11,23 +14,26 @@ module Roast
11
14
  attr_accessor :file,
12
15
  :concise,
13
16
  :output_file,
14
- :subject_file,
15
17
  :verbose,
16
18
  :name,
17
19
  :context_path,
18
- :output
20
+ :output,
21
+ :resource,
22
+ :session_name,
23
+ :session_timestamp,
24
+ :configuration
19
25
 
20
- def initialize(file, subject_file = nil, name: nil, context_path: nil)
26
+ def initialize(file = nil, name: nil, context_path: nil, resource: nil, session_name: nil, configuration: nil)
21
27
  @file = file
22
- @subject_file = subject_file
23
28
  @name = name || self.class.name.underscore.split("/").last
24
29
  @context_path = context_path || determine_context_path
25
30
  @final_output = []
26
31
  @output = {}
32
+ @resource = resource || Roast::Resources.for(file)
33
+ @session_name = session_name || @name
34
+ @session_timestamp = nil
35
+ @configuration = configuration
27
36
  transcript << { system: read_sidecar_prompt }
28
- unless subject_file.blank?
29
- transcript << { user: read_subject_file }
30
- end
31
37
  Roast::Tools.setup_interrupt_handler(transcript)
32
38
  Roast::Tools.setup_exit_handler(self)
33
39
  end
@@ -37,7 +43,42 @@ module Roast
37
43
  end
38
44
 
39
45
  def final_output
40
- @final_output.join("\n")
46
+ @final_output.join("\n\n")
47
+ end
48
+
49
+ # Override chat_completion to add instrumentation
50
+ def chat_completion(**kwargs)
51
+ start_time = Time.now
52
+ model = kwargs[:openai] || "default"
53
+
54
+ ActiveSupport::Notifications.instrument("roast.chat_completion.start", {
55
+ model: model,
56
+ parameters: kwargs.except(:openai),
57
+ })
58
+
59
+ result = super(**kwargs)
60
+ execution_time = Time.now - start_time
61
+
62
+ ActiveSupport::Notifications.instrument("roast.chat_completion.complete", {
63
+ success: true,
64
+ model: model,
65
+ parameters: kwargs.except(:openai),
66
+ execution_time: execution_time,
67
+ response_size: result.to_s.length,
68
+ })
69
+
70
+ result
71
+ rescue => e
72
+ execution_time = Time.now - start_time
73
+
74
+ ActiveSupport::Notifications.instrument("roast.chat_completion.error", {
75
+ error: e.class.name,
76
+ message: e.message,
77
+ model: model,
78
+ parameters: kwargs.except(:openai),
79
+ execution_time: execution_time,
80
+ })
81
+ raise
41
82
  end
42
83
 
43
84
  private
@@ -66,14 +107,6 @@ module Roast
66
107
  def read_sidecar_prompt
67
108
  Roast::Helpers::PromptLoader.load_prompt(self, file)
68
109
  end
69
-
70
- def read_subject_file
71
- [
72
- "# SUT (Subject Under Test)",
73
- "# #{subject_file}",
74
- File.read(subject_file),
75
- ].join("\n")
76
- end
77
110
  end
78
111
  end
79
112
  end
@@ -8,7 +8,7 @@ module Roast
8
8
  # Encapsulates workflow configuration data and provides structured access
9
9
  # to the configuration settings
10
10
  class Configuration
11
- attr_reader :config_hash, :workflow_path, :name, :steps, :tools, :function_configs
11
+ attr_reader :config_hash, :workflow_path, :name, :steps, :tools, :function_configs, :api_token, :model, :resource
12
12
  attr_accessor :target
13
13
 
14
14
  def initialize(workflow_path, options = {})
@@ -30,6 +30,23 @@ module Roast
30
30
 
31
31
  # Process the target command if it's a shell command
32
32
  @target = process_target(@target) if has_target?
33
+
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
42
+
43
+ # Process API token if provided
44
+ if @config_hash["api_token"]
45
+ @api_token = process_shell_command(@config_hash["api_token"])
46
+ end
47
+
48
+ # Extract default model if provided
49
+ @model = @config_hash["model"]
33
50
  end
34
51
 
35
52
  def context_path
@@ -48,8 +65,49 @@ module Roast
48
65
  @config_hash[step_name] || {}
49
66
  end
50
67
 
51
- def find_step_index(steps, target_step)
52
- steps.each_with_index do |step, index|
68
+ # Find the index of a step in the workflow steps array
69
+ # @param [Array] steps Optional - The steps array to search (defaults to self.steps)
70
+ # @param [String] target_step The name of the step to find
71
+ # @return [Integer, nil] The index of the step, or nil if not found
72
+ def find_step_index(steps_array = nil, target_step = nil)
73
+ # Handle different call patterns for backward compatibility
74
+ if steps_array.is_a?(String) && target_step.nil?
75
+ target_step = steps_array
76
+ steps_array = steps
77
+ elsif steps_array.is_a?(Array) && target_step.is_a?(String)
78
+ # This is the normal case - steps_array and target_step are provided
79
+ else
80
+ # Default to self.steps if just the target_step is provided
81
+ steps_array = steps
82
+ end
83
+
84
+ # First, try using the new more detailed search
85
+ steps_array.each_with_index do |step, index|
86
+ case step
87
+ when Hash
88
+ # Could be {name: command} or {name: {substeps}}
89
+ step_key = step.keys.first
90
+ return index if step_key == target_step
91
+ when Array
92
+ # This is a parallel step container, search inside it
93
+ found = step.any? do |substep|
94
+ case substep
95
+ when Hash
96
+ substep.keys.first == target_step
97
+ when String
98
+ substep == target_step
99
+ else
100
+ false
101
+ end
102
+ end
103
+ return index if found
104
+ when String
105
+ return index if step == target_step
106
+ end
107
+ end
108
+
109
+ # Fall back to the original method using extract_step_name
110
+ steps_array.each_with_index do |step, index|
53
111
  step_name = extract_step_name(step)
54
112
  if step_name.is_a?(Array)
55
113
  # For arrays (parallel steps), check if target is in the array
@@ -58,6 +116,7 @@ module Roast
58
116
  return index
59
117
  end
60
118
  end
119
+
61
120
  nil
62
121
  end
63
122
 
@@ -81,8 +140,8 @@ module Roast
81
140
 
82
141
  private
83
142
 
84
- def process_target(command)
85
- # If it's a bash command with the new $(command) syntax
143
+ def process_shell_command(command)
144
+ # If it's a bash command with the $(command) syntax
86
145
  if command =~ /^\$\((.*)\)$/
87
146
  return Open3.capture2e({}, ::Regexp.last_match(1)).first.strip
88
147
  end
@@ -92,13 +151,29 @@ module Roast
92
151
  return Open3.capture2e({}, *command.split(" ")[1..-1]).first.strip
93
152
  end
94
153
 
154
+ # Not a shell command, return as is
155
+ command
156
+ end
157
+
158
+ def process_target(command)
159
+ # Process shell command first
160
+ processed = process_shell_command(command)
161
+
95
162
  # If it's a glob pattern, return the full paths of the files it matches
96
- if command.include?("*")
97
- return Dir.glob(command).map { |file| File.expand_path(file) }.join("\n")
163
+ if processed.include?("*")
164
+ matched_files = Dir.glob(processed)
165
+ # If no files match, return the pattern itself
166
+ return processed if matched_files.empty?
167
+
168
+ return matched_files.map { |file| File.expand_path(file) }.join("\n")
98
169
  end
99
170
 
171
+ # For tests, if the command was already processed as a shell command and is simple,
172
+ # don't expand the path to avoid breaking existing tests
173
+ return processed if command != processed && !processed.include?("/")
174
+
100
175
  # assumed to be a direct file path(s)
101
- File.expand_path(command)
176
+ File.expand_path(processed)
102
177
  end
103
178
 
104
179
  def extract_step_name(step)
@@ -3,6 +3,9 @@
3
3
  require_relative "workflow_executor"
4
4
  require_relative "configuration"
5
5
  require_relative "../helpers/function_caching_interceptor"
6
+ require "active_support"
7
+ require "active_support/isolated_execution_state"
8
+ require "active_support/notifications"
6
9
 
7
10
  module Roast
8
11
  module Workflow
@@ -17,10 +20,14 @@ module Roast
17
20
  @configuration = Configuration.new(workflow_path, options)
18
21
  @options = options
19
22
  @files = files
23
+ @replay_processed = false # Initialize replay tracking
20
24
  include_tools
25
+ load_roast_initializers
26
+ configure_api_client
21
27
  end
22
28
 
23
29
  def begin!
30
+ start_time = Time.now
24
31
  $stderr.puts "Starting workflow..."
25
32
  $stderr.puts "Workflow: #{configuration.workflow_path}"
26
33
  $stderr.puts "Options: #{options}"
@@ -28,6 +35,12 @@ module Roast
28
35
  name = configuration.basename
29
36
  context_path = configuration.context_path
30
37
 
38
+ ActiveSupport::Notifications.instrument("roast.workflow.start", {
39
+ workflow_path: configuration.workflow_path,
40
+ options: options,
41
+ name: name,
42
+ })
43
+
31
44
  if files.any?
32
45
  $stderr.puts "WARNING: Ignoring target parameter because files were provided: #{configuration.target}" if configuration.has_target?
33
46
  files.each do |file|
@@ -42,17 +55,37 @@ module Roast
42
55
  parse(configuration.steps)
43
56
  end
44
57
  else
45
- $stdout.puts "🚫 ERROR: No files or target provided! 🚫"
58
+ # Handle targetless workflow - run once without a specific target
59
+ $stderr.puts "Running targetless workflow"
60
+ setup_workflow(nil, name:, context_path:)
61
+ parse(configuration.steps)
46
62
  end
63
+ ensure
64
+ execution_time = Time.now - start_time
65
+
66
+ ActiveSupport::Notifications.instrument("roast.workflow.complete", {
67
+ workflow_path: configuration.workflow_path,
68
+ success: !$ERROR_INFO,
69
+ execution_time: execution_time,
70
+ })
47
71
  end
48
72
 
49
73
  private
50
74
 
51
75
  def setup_workflow(file, name:, context_path:)
52
- @current_workflow = BaseWorkflow.new(file, name:, context_path:).tap do |workflow|
76
+ session_name = configuration.name
77
+
78
+ @current_workflow = BaseWorkflow.new(
79
+ file,
80
+ name: name,
81
+ context_path: context_path,
82
+ resource: configuration.resource,
83
+ session_name: session_name,
84
+ configuration: configuration,
85
+ ).tap do |workflow|
53
86
  workflow.output_file = options[:output] if options[:output].present?
54
- workflow.subject_file = options[:subject] if options[:subject].present?
55
87
  workflow.verbose = options[:verbose] if options[:verbose].present?
88
+ workflow.concise = options[:concise] if options[:concise].present?
56
89
  end
57
90
  end
58
91
 
@@ -64,15 +97,108 @@ module Roast
64
97
  BaseWorkflow.include(*configuration.tools.map(&:constantize))
65
98
  end
66
99
 
100
+ def load_roast_initializers
101
+ # Project-specific initializers
102
+ project_initializers = File.join(Dir.pwd, ".roast", "initializers")
103
+
104
+ if Dir.exist?(project_initializers)
105
+ $stderr.puts "Loading project initializers from #{project_initializers}"
106
+ Dir.glob(File.join(project_initializers, "**/*.rb")).sort.each do |file|
107
+ $stderr.puts "Loading initializer: #{file}"
108
+ require file
109
+ end
110
+ end
111
+ rescue => e
112
+ Roast::Helpers::Logger.error("Error loading initializers: #{e.message}")
113
+ # Don't fail the workflow if initializers can't be loaded
114
+ end
115
+
116
+ def configure_api_client
117
+ return unless configuration.api_token
118
+
119
+ begin
120
+ require "raix"
121
+
122
+ # Configure OpenAI client with the token
123
+ $stderr.puts "Configuring API client with token from workflow"
124
+
125
+ # Initialize the OpenAI client if it doesn't exist
126
+ if defined?(Raix.configuration.openai_client)
127
+ # Create a new client with the token
128
+ Raix.configuration.openai_client = OpenAI::Client.new(access_token: configuration.api_token)
129
+ else
130
+ require "openai"
131
+
132
+ Raix.configure do |config|
133
+ config.openai_client = OpenAI::Client.new(access_token: configuration.api_token)
134
+ end
135
+ end
136
+ rescue => e
137
+ Roast::Helpers::Logger.error("Error configuring API client: #{e.message}")
138
+ # Don't fail the workflow if client can't be configured
139
+ end
140
+ end
141
+
142
+ def load_state_and_update_steps(steps, skip_until, step_name, timestamp)
143
+ state_repository = FileStateRepository.new
144
+
145
+ if timestamp
146
+ if state_repository.load_state_before_step(current_workflow, step_name, timestamp: timestamp)
147
+ $stderr.puts "Loaded saved state for step #{step_name} in session #{timestamp}"
148
+ else
149
+ $stderr.puts "Could not find saved state for step #{step_name} in session #{timestamp}, running from requested step"
150
+ end
151
+ elsif state_repository.load_state_before_step(current_workflow, step_name)
152
+ $stderr.puts "Loaded saved state for step #{step_name}"
153
+ else
154
+ $stderr.puts "Could not find saved state for step #{step_name}, running from requested step"
155
+ end
156
+
157
+ # Always return steps from the requested index, regardless of state loading success
158
+ steps[skip_until..-1]
159
+ end
160
+
67
161
  def parse(steps)
68
162
  return run(steps) if steps.is_a?(String)
69
163
 
164
+ # Handle replay option - skip to the specified step
165
+ if @options[:replay] && !@replay_processed
166
+ replay_param = @options[:replay]
167
+ timestamp = nil
168
+ step_name = replay_param
169
+
170
+ # Check if timestamp is prepended (format: timestamp:step_name)
171
+ if replay_param.include?(":")
172
+ timestamp, step_name = replay_param.split(":", 2)
173
+
174
+ # Validate timestamp format (YYYYMMDD_HHMMSS_LLL)
175
+ unless timestamp.match?(/^\d{8}_\d{6}_\d{3}$/)
176
+ raise ArgumentError, "Invalid timestamp format: #{timestamp}. Expected YYYYMMDD_HHMMSS_LLL"
177
+ end
178
+ end
179
+
180
+ # Find step index by iterating through the steps
181
+ skip_until = find_step_index_in_array(steps, step_name)
182
+
183
+ if skip_until
184
+ $stderr.puts "Replaying from step: #{step_name}#{timestamp ? " (session: #{timestamp})" : ""}"
185
+ current_workflow.session_timestamp = timestamp if timestamp
186
+ steps = load_state_and_update_steps(steps, skip_until, step_name, timestamp)
187
+ else
188
+ $stderr.puts "Step #{step_name} not found in workflow, running from beginning"
189
+ end
190
+ @replay_processed = true # Mark that we've processed replay, so we don't do it again in recursive calls
191
+ end
192
+
70
193
  # Use the WorkflowExecutor to execute the steps
71
194
  executor = WorkflowExecutor.new(current_workflow, configuration.config_hash, configuration.context_path)
72
195
  executor.execute_steps(steps)
73
196
 
74
197
  $stderr.puts "🔥🔥🔥 ROAST COMPLETE! 🔥🔥🔥"
75
198
 
199
+ # Save the final output to the session directory
200
+ save_final_output(current_workflow)
201
+
76
202
  # Save results to file if specified
77
203
  if current_workflow.output_file
78
204
  File.write(current_workflow.output_file, current_workflow.final_output)
@@ -87,6 +213,48 @@ module Roast
87
213
  executor = WorkflowExecutor.new(current_workflow, configuration.config_hash, configuration.context_path)
88
214
  executor.execute_step(name)
89
215
  end
216
+
217
+ def find_step_index_in_array(steps_array, step_name)
218
+ steps_array.each_with_index do |step, index|
219
+ case step
220
+ when Hash
221
+ # Could be {name: command} or {name: {substeps}}
222
+ step_key = step.keys.first
223
+ return index if step_key == step_name
224
+ when Array
225
+ # This is a parallel step container, search inside it
226
+ step.each_with_index do |substep, _substep_index|
227
+ case substep
228
+ when Hash
229
+ # Could be {name: command}
230
+ substep_key = substep.keys.first
231
+ return index if substep_key == step_name
232
+ when String
233
+ return index if substep == step_name
234
+ end
235
+ end
236
+ when String
237
+ return index if step == step_name
238
+ end
239
+ end
240
+ nil
241
+ end
242
+
243
+ def save_final_output(workflow)
244
+ return unless workflow.respond_to?(:session_name) && workflow.session_name && workflow.respond_to?(:final_output)
245
+
246
+ begin
247
+ final_output = workflow.final_output.to_s
248
+ return if final_output.empty?
249
+
250
+ state_repository = FileStateRepository.new
251
+ output_file = state_repository.save_final_output(workflow, final_output)
252
+ $stderr.puts "Final output saved to: #{output_file}" if output_file
253
+ rescue => e
254
+ # Don't fail if saving output fails
255
+ $stderr.puts "Warning: Failed to save final output to session: #{e.message}"
256
+ end
257
+ end
90
258
  end
91
259
  end
92
260
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require_relative "session_manager"
6
+ require_relative "state_repository"
7
+
8
+ module Roast
9
+ module Workflow
10
+ # File-based implementation of StateRepository
11
+ # Handles state persistence to the filesystem in a thread-safe manner
12
+ class FileStateRepository < StateRepository
13
+ def initialize(session_manager = SessionManager.new)
14
+ super()
15
+ @state_mutex = Mutex.new
16
+ @session_manager = session_manager
17
+ end
18
+
19
+ def save_state(workflow, step_name, state_data)
20
+ @state_mutex.synchronize do
21
+ # If workflow doesn't have a timestamp, let the session manager create one
22
+ workflow.session_timestamp ||= @session_manager.create_new_session(workflow.object_id)
23
+
24
+ session_dir = @session_manager.ensure_session_directory(
25
+ workflow.object_id,
26
+ workflow.session_name,
27
+ workflow.file,
28
+ timestamp: workflow.session_timestamp,
29
+ )
30
+ step_file = File.join(session_dir, format_step_filename(state_data[:order], step_name))
31
+ File.write(step_file, JSON.pretty_generate(state_data))
32
+ end
33
+ rescue => e
34
+ $stderr.puts "Failed to save state for step #{step_name}: #{e.message}"
35
+ end
36
+
37
+ def load_state_before_step(workflow, step_name, timestamp: nil)
38
+ session_dir = @session_manager.find_session_directory(workflow.session_name, workflow.file, timestamp)
39
+ return false unless session_dir
40
+
41
+ step_files = find_step_files(session_dir)
42
+ target_index = find_step_before(step_files, step_name)
43
+ return false if target_index.nil? || target_index < 0
44
+
45
+ state_data = load_state_file(step_files[target_index])
46
+
47
+ # If no timestamp provided and workflow has no session, copy states to new session
48
+ should_copy = !timestamp && workflow.session_timestamp.nil?
49
+
50
+ copy_states_to_new_session(workflow, session_dir, step_files[0..target_index]) if should_copy
51
+ state_data
52
+ end
53
+
54
+ def save_final_output(workflow, output_content)
55
+ return if output_content.empty?
56
+
57
+ session_dir = @session_manager.ensure_session_directory(
58
+ workflow.object_id,
59
+ workflow.session_name,
60
+ workflow.file,
61
+ timestamp: workflow.session_timestamp,
62
+ )
63
+ output_file = File.join(session_dir, "final_output.txt")
64
+ File.write(output_file, output_content)
65
+ $stderr.puts "Final output saved to: #{output_file}"
66
+ output_file
67
+ rescue => e
68
+ $stderr.puts "Failed to save final output: #{e.message}"
69
+ nil
70
+ end
71
+
72
+ private
73
+
74
+ def find_step_files(session_dir)
75
+ Dir.glob(File.join(session_dir, "step_*_*.json")).sort_by do |file|
76
+ file[/step_(\d+)_/, 1].to_i
77
+ end
78
+ end
79
+
80
+ def find_step_before(step_files, target_step_name)
81
+ step_files.each_with_index do |file, index|
82
+ if file.end_with?("_#{target_step_name}.json")
83
+ return index - 1
84
+ end
85
+ end
86
+ nil
87
+ end
88
+
89
+ def load_state_file(state_file)
90
+ JSON.parse(File.read(state_file), symbolize_names: true)
91
+ end
92
+
93
+ def copy_states_to_new_session(workflow, source_session_dir, state_files)
94
+ # Create a new session for the workflow
95
+ new_timestamp = @session_manager.create_new_session(workflow.object_id)
96
+ workflow.session_timestamp = new_timestamp
97
+
98
+ # Get the new session directory path
99
+ current_session_dir = @session_manager.ensure_session_directory(
100
+ workflow.object_id,
101
+ workflow.session_name,
102
+ workflow.file,
103
+ timestamp: workflow.session_timestamp,
104
+ )
105
+
106
+ # Skip copying if the source and destination are the same
107
+ return if source_session_dir == current_session_dir
108
+
109
+ # Make sure the new directory actually exists before copying
110
+ FileUtils.mkdir_p(current_session_dir) unless File.directory?(current_session_dir)
111
+
112
+ # Copy each state file to the new session directory
113
+ state_files.each do |state_file|
114
+ FileUtils.cp(state_file, current_session_dir)
115
+ end
116
+
117
+ # Return success
118
+ true
119
+ end
120
+
121
+ def format_step_filename(order, step_name)
122
+ "step_#{order.to_s.rjust(3, "0")}_#{step_name}.json"
123
+ end
124
+ end
125
+ end
126
+ end