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.
- checksums.yaml +4 -4
- data/.github/workflows/cla.yml +1 -1
- data/.gitignore +1 -0
- data/CHANGELOG.md +20 -0
- data/CLAUDE.md +3 -1
- data/Gemfile +0 -1
- data/Gemfile.lock +3 -4
- data/README.md +418 -4
- data/Rakefile +1 -6
- data/docs/INSTRUMENTATION.md +202 -0
- data/examples/api_workflow/README.md +85 -0
- data/examples/api_workflow/fetch_api_data/prompt.md +10 -0
- data/examples/api_workflow/generate_report/prompt.md +10 -0
- data/examples/api_workflow/prompt.md +10 -0
- data/examples/api_workflow/transform_data/prompt.md +10 -0
- data/examples/api_workflow/workflow.yml +30 -0
- data/examples/grading/workflow.yml +2 -2
- data/examples/instrumentation.rb +76 -0
- data/examples/rspec_to_minitest/README.md +68 -0
- data/examples/rspec_to_minitest/analyze_spec/prompt.md +30 -0
- data/examples/rspec_to_minitest/create_minitest/prompt.md +33 -0
- data/examples/rspec_to_minitest/run_and_improve/prompt.md +35 -0
- data/examples/rspec_to_minitest/workflow.md +10 -0
- data/examples/rspec_to_minitest/workflow.yml +40 -0
- data/lib/roast/helpers/function_caching_interceptor.rb +72 -8
- data/lib/roast/helpers/prompt_loader.rb +2 -0
- data/lib/roast/resources/api_resource.rb +137 -0
- data/lib/roast/resources/base_resource.rb +47 -0
- data/lib/roast/resources/directory_resource.rb +40 -0
- data/lib/roast/resources/file_resource.rb +33 -0
- data/lib/roast/resources/none_resource.rb +29 -0
- data/lib/roast/resources/url_resource.rb +63 -0
- data/lib/roast/resources.rb +100 -0
- data/lib/roast/tools/coding_agent.rb +69 -0
- data/lib/roast/tools.rb +1 -0
- data/lib/roast/version.rb +1 -1
- data/lib/roast/workflow/base_step.rb +21 -17
- data/lib/roast/workflow/base_workflow.rb +49 -16
- data/lib/roast/workflow/configuration.rb +83 -8
- data/lib/roast/workflow/configuration_parser.rb +171 -3
- data/lib/roast/workflow/file_state_repository.rb +126 -0
- data/lib/roast/workflow/prompt_step.rb +16 -0
- data/lib/roast/workflow/session_manager.rb +82 -0
- data/lib/roast/workflow/state_repository.rb +21 -0
- data/lib/roast/workflow/workflow_executor.rb +99 -9
- data/lib/roast/workflow.rb +4 -0
- data/lib/roast.rb +2 -5
- data/roast.gemspec +1 -1
- data/schema/workflow.json +12 -0
- metadata +31 -6
- 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, :
|
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
|
-
@
|
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:,
|
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,
|
37
|
-
workflow.chat_completion(openai: model, loop
|
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
|
-
|
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
|
-
|
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
|
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
|
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
|
-
|
52
|
-
|
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
|
85
|
-
# If it's a bash command with the
|
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
|
97
|
-
|
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(
|
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
|
-
|
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
|
-
|
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
|