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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yaml +1 -1
- data/CHANGELOG.md +48 -0
- data/CLAUDE.md +20 -0
- data/Gemfile +1 -0
- data/Gemfile.lock +11 -6
- data/README.md +225 -13
- data/bin/roast +27 -0
- data/docs/INSTRUMENTATION.md +42 -1
- data/docs/ITERATION_SYNTAX.md +119 -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/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 +5 -1
- 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/openrouter_example/workflow.yml +2 -2
- 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/update_files.rb +413 -0
- data/lib/roast/tools.rb +12 -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 +165 -0
- data/lib/roast/workflow/base_step.rb +4 -24
- data/lib/roast/workflow/base_workflow.rb +76 -73
- 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 +96 -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_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 +85 -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/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 +205 -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 +154 -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 +117 -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 +3 -1
- data/schema/workflow.json +85 -0
- metadata +112 -4
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_support/core_ext/hash/indifferent_access"
|
4
|
+
require "roast/workflow/dot_access_hash"
|
5
|
+
|
6
|
+
module Roast
|
7
|
+
module Workflow
|
8
|
+
# Manages workflow output, including both the key-value output hash
|
9
|
+
# and the final output string/array
|
10
|
+
class OutputManager
|
11
|
+
def initialize
|
12
|
+
@output = ActiveSupport::HashWithIndifferentAccess.new
|
13
|
+
@output_wrapper = nil
|
14
|
+
@final_output = []
|
15
|
+
end
|
16
|
+
|
17
|
+
# Get output wrapped in DotAccessHash for dot notation access
|
18
|
+
def output
|
19
|
+
@output_wrapper ||= DotAccessHash.new(@output)
|
20
|
+
end
|
21
|
+
|
22
|
+
# Set output, ensuring it's always a HashWithIndifferentAccess
|
23
|
+
def output=(value)
|
24
|
+
@output = if value.is_a?(ActiveSupport::HashWithIndifferentAccess)
|
25
|
+
value
|
26
|
+
else
|
27
|
+
ActiveSupport::HashWithIndifferentAccess.new(value)
|
28
|
+
end
|
29
|
+
# Reset the wrapper when output changes
|
30
|
+
@output_wrapper = nil
|
31
|
+
end
|
32
|
+
|
33
|
+
# Append a message to the final output
|
34
|
+
def append_to_final_output(message)
|
35
|
+
@final_output << message
|
36
|
+
end
|
37
|
+
|
38
|
+
# Get the final output as a string
|
39
|
+
def final_output
|
40
|
+
return @final_output if @final_output.is_a?(String)
|
41
|
+
return "" if @final_output.nil?
|
42
|
+
|
43
|
+
# Handle array case (expected normal case)
|
44
|
+
if @final_output.respond_to?(:join)
|
45
|
+
@final_output.join("\n\n")
|
46
|
+
else
|
47
|
+
# Handle any other unexpected type by converting to string
|
48
|
+
@final_output.to_s
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
# Set the final output directly (used when loading from state)
|
53
|
+
attr_writer :final_output
|
54
|
+
|
55
|
+
# Get the raw output hash (for internal use)
|
56
|
+
def raw_output
|
57
|
+
@output
|
58
|
+
end
|
59
|
+
|
60
|
+
# Get a snapshot of the current state for persistence
|
61
|
+
def to_h
|
62
|
+
{
|
63
|
+
output: @output.to_h,
|
64
|
+
final_output: @final_output,
|
65
|
+
}
|
66
|
+
end
|
67
|
+
|
68
|
+
# Restore state from a hash
|
69
|
+
def from_h(data)
|
70
|
+
return unless data
|
71
|
+
|
72
|
+
self.output = data[:output] if data.key?(:output)
|
73
|
+
self.final_output = data[:final_output] if data.key?(:final_output)
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,49 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
# Executes workflow steps in parallel using threads
|
6
|
+
class ParallelExecutor
|
7
|
+
class << self
|
8
|
+
def execute(steps, executor)
|
9
|
+
new(executor).execute(steps)
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
def initialize(executor)
|
14
|
+
@executor = executor
|
15
|
+
end
|
16
|
+
|
17
|
+
def execute(steps)
|
18
|
+
threads = steps.map do |sub_step|
|
19
|
+
Thread.new do
|
20
|
+
# Each thread needs its own isolated execution context
|
21
|
+
Thread.current[:step] = sub_step
|
22
|
+
Thread.current[:result] = nil
|
23
|
+
Thread.current[:error] = nil
|
24
|
+
|
25
|
+
begin
|
26
|
+
# Execute the single step in this thread
|
27
|
+
@executor.execute_steps([sub_step])
|
28
|
+
Thread.current[:result] = :success
|
29
|
+
rescue => e
|
30
|
+
Thread.current[:error] = e
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Wait for all threads to complete
|
36
|
+
threads.each(&:join)
|
37
|
+
|
38
|
+
# Check for errors in any thread
|
39
|
+
threads.each_with_index do |thread, _index|
|
40
|
+
if thread[:error]
|
41
|
+
raise thread[:error]
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
:success
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
@@ -0,0 +1,75 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
# Executes steps repeatedly until a condition is met or max_iterations is reached
|
6
|
+
class RepeatStep < BaseIterationStep
|
7
|
+
attr_reader :until_condition, :max_iterations
|
8
|
+
|
9
|
+
def initialize(workflow, steps:, until_condition:, max_iterations: DEFAULT_MAX_ITERATIONS, **kwargs)
|
10
|
+
super(workflow, steps: steps, **kwargs)
|
11
|
+
@until_condition = until_condition
|
12
|
+
@max_iterations = max_iterations.to_i
|
13
|
+
|
14
|
+
# Ensure max_iterations is at least 1
|
15
|
+
@max_iterations = 1 if @max_iterations < 1
|
16
|
+
end
|
17
|
+
|
18
|
+
def call
|
19
|
+
iteration = 0
|
20
|
+
results = []
|
21
|
+
|
22
|
+
$stderr.puts "Starting repeat loop with max_iterations: #{@max_iterations}"
|
23
|
+
|
24
|
+
begin
|
25
|
+
# Loop until condition is met or max_iterations is reached
|
26
|
+
# Process the until_condition based on its type with Boolean coercion
|
27
|
+
until process_iteration_input(@until_condition, workflow, coerce_to: :boolean) || (iteration >= @max_iterations)
|
28
|
+
$stderr.puts "Repeat loop iteration #{iteration + 1}"
|
29
|
+
|
30
|
+
# Execute the nested steps
|
31
|
+
step_results = execute_nested_steps(@steps, workflow)
|
32
|
+
results << step_results
|
33
|
+
|
34
|
+
# Increment iteration counter
|
35
|
+
iteration += 1
|
36
|
+
|
37
|
+
# Save state after each iteration if the workflow supports it
|
38
|
+
save_iteration_state(iteration) if workflow.respond_to?(:session_name) && workflow.session_name
|
39
|
+
end
|
40
|
+
|
41
|
+
if iteration >= @max_iterations
|
42
|
+
$stderr.puts "Repeat loop reached maximum iterations (#{@max_iterations})"
|
43
|
+
else
|
44
|
+
$stderr.puts "Repeat loop condition satisfied after #{iteration} iterations"
|
45
|
+
end
|
46
|
+
|
47
|
+
# Return the results of all iterations
|
48
|
+
results
|
49
|
+
rescue => e
|
50
|
+
$stderr.puts "Error in repeat loop: #{e.message}"
|
51
|
+
raise
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
private
|
56
|
+
|
57
|
+
def save_iteration_state(iteration)
|
58
|
+
state_repository = FileStateRepository.new
|
59
|
+
|
60
|
+
# Save the current iteration count in the state
|
61
|
+
state_data = {
|
62
|
+
step_name: name,
|
63
|
+
iteration: iteration,
|
64
|
+
output: workflow.respond_to?(:output) ? workflow.output.clone : {},
|
65
|
+
transcript: workflow.respond_to?(:transcript) ? workflow.transcript.map(&:itself) : [],
|
66
|
+
}
|
67
|
+
|
68
|
+
state_repository.save_state(workflow, "#{name}_iteration_#{iteration}", state_data)
|
69
|
+
rescue => e
|
70
|
+
# Don't fail the workflow if state saving fails
|
71
|
+
$stderr.puts "Warning: Failed to save iteration state: #{e.message}"
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/workflow/step_finder"
|
4
|
+
require "roast/workflow/file_state_repository"
|
5
|
+
|
6
|
+
module Roast
|
7
|
+
module Workflow
|
8
|
+
# Handles replay functionality for workflows
|
9
|
+
# Manages skipping to specific steps and loading previous state
|
10
|
+
class ReplayHandler
|
11
|
+
attr_reader :processed
|
12
|
+
|
13
|
+
def initialize(workflow, state_repository: nil)
|
14
|
+
@workflow = workflow
|
15
|
+
@state_repository = state_repository || FileStateRepository.new
|
16
|
+
@processed = false
|
17
|
+
end
|
18
|
+
|
19
|
+
def process_replay(steps, replay_option)
|
20
|
+
return steps unless replay_option && !@processed
|
21
|
+
|
22
|
+
timestamp, step_name = parse_replay_option(replay_option)
|
23
|
+
skip_index = StepFinder.find_index(steps, step_name)
|
24
|
+
|
25
|
+
if skip_index
|
26
|
+
$stderr.puts "Replaying from step: #{step_name}#{timestamp ? " (session: #{timestamp})" : ""}"
|
27
|
+
@workflow.session_timestamp = timestamp if timestamp && @workflow.respond_to?(:session_timestamp=)
|
28
|
+
steps = load_state_and_get_remaining_steps(steps, skip_index, step_name, timestamp)
|
29
|
+
else
|
30
|
+
$stderr.puts "Step #{step_name} not found in workflow, running from beginning"
|
31
|
+
end
|
32
|
+
|
33
|
+
@processed = true
|
34
|
+
steps
|
35
|
+
end
|
36
|
+
|
37
|
+
def load_state_and_restore(step_name, timestamp: nil)
|
38
|
+
state_data = if timestamp
|
39
|
+
$stderr.puts "Looking for state before '#{step_name}' in session #{timestamp}..."
|
40
|
+
@state_repository.load_state_before_step(@workflow, step_name, timestamp: timestamp)
|
41
|
+
else
|
42
|
+
$stderr.puts "Looking for state before '#{step_name}' in most recent session..."
|
43
|
+
@state_repository.load_state_before_step(@workflow, step_name)
|
44
|
+
end
|
45
|
+
|
46
|
+
if state_data
|
47
|
+
$stderr.puts "Successfully loaded state with data from previous step"
|
48
|
+
restore_workflow_state(state_data)
|
49
|
+
else
|
50
|
+
session_info = timestamp ? " in session #{timestamp}" : ""
|
51
|
+
$stderr.puts "Could not find suitable state data from a previous step to '#{step_name}'#{session_info}."
|
52
|
+
$stderr.puts "Will run workflow from '#{step_name}' without prior context."
|
53
|
+
end
|
54
|
+
|
55
|
+
state_data
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def parse_replay_option(replay_param)
|
61
|
+
return [nil, replay_param] unless replay_param.include?(":")
|
62
|
+
|
63
|
+
timestamp, step_name = replay_param.split(":", 2)
|
64
|
+
|
65
|
+
# Validate timestamp format (YYYYMMDD_HHMMSS_LLL)
|
66
|
+
unless timestamp.match?(/^\d{8}_\d{6}_\d{3}$/)
|
67
|
+
raise ArgumentError, "Invalid timestamp format: #{timestamp}. Expected YYYYMMDD_HHMMSS_LLL"
|
68
|
+
end
|
69
|
+
|
70
|
+
[timestamp, step_name]
|
71
|
+
end
|
72
|
+
|
73
|
+
def load_state_and_get_remaining_steps(steps, skip_index, step_name, timestamp)
|
74
|
+
load_state_and_restore(step_name, timestamp: timestamp)
|
75
|
+
# Always return steps from the requested index, regardless of state loading success
|
76
|
+
steps[skip_index..-1]
|
77
|
+
end
|
78
|
+
|
79
|
+
def restore_workflow_state(state_data)
|
80
|
+
return unless state_data && @workflow
|
81
|
+
|
82
|
+
restore_output(state_data)
|
83
|
+
restore_transcript(state_data)
|
84
|
+
restore_final_output(state_data)
|
85
|
+
end
|
86
|
+
|
87
|
+
def restore_output(state_data)
|
88
|
+
return unless state_data.key?(:output)
|
89
|
+
return unless @workflow.respond_to?(:output=)
|
90
|
+
|
91
|
+
@workflow.output = state_data[:output]
|
92
|
+
end
|
93
|
+
|
94
|
+
def restore_transcript(state_data)
|
95
|
+
return unless state_data.key?(:transcript)
|
96
|
+
return unless @workflow.respond_to?(:transcript)
|
97
|
+
|
98
|
+
# Transcript is an array from Raix::ChatCompletion
|
99
|
+
# We need to clear it and repopulate it
|
100
|
+
if @workflow.transcript.respond_to?(:clear) && @workflow.transcript.respond_to?(:<<)
|
101
|
+
@workflow.transcript.clear
|
102
|
+
state_data[:transcript].each do |message|
|
103
|
+
@workflow.transcript << message
|
104
|
+
end
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
def restore_final_output(state_data)
|
109
|
+
return unless state_data.key?(:final_output)
|
110
|
+
|
111
|
+
# Make sure final_output is always handled as an array
|
112
|
+
final_output = state_data[:final_output]
|
113
|
+
final_output = [final_output] if final_output.is_a?(String)
|
114
|
+
|
115
|
+
if @workflow.respond_to?(:final_output=)
|
116
|
+
@workflow.final_output = final_output
|
117
|
+
elsif @workflow.instance_variable_defined?(:@final_output)
|
118
|
+
@workflow.instance_variable_set(:@final_output, final_output)
|
119
|
+
end
|
120
|
+
end
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "open3"
|
4
|
+
require "roast/resources"
|
5
|
+
|
6
|
+
module Roast
|
7
|
+
module Workflow
|
8
|
+
# Handles resource resolution and target processing
|
9
|
+
# Extracts file/resource handling logic from Configuration
|
10
|
+
class ResourceResolver
|
11
|
+
class << self
|
12
|
+
# Process the target and create appropriate resource object
|
13
|
+
# @param target [String, nil] The target from configuration or options
|
14
|
+
# @param context_path [String] The directory containing the workflow file
|
15
|
+
# @return [Roast::Resources::BaseResource] The resolved resource object
|
16
|
+
def resolve(target, context_path)
|
17
|
+
return Roast::Resources::NoneResource.new(nil) unless has_target?(target)
|
18
|
+
|
19
|
+
processed_target = process_target(target, context_path)
|
20
|
+
Roast::Resources.for(processed_target)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Process target through shell command expansion and glob pattern matching
|
24
|
+
# @param target [String] The raw target string
|
25
|
+
# @param context_path [String] The directory containing the workflow file
|
26
|
+
# @return [String] The processed target
|
27
|
+
def process_target(target, context_path)
|
28
|
+
# Process shell command first
|
29
|
+
processed = process_shell_command(target)
|
30
|
+
|
31
|
+
# If it's a glob pattern, return the full paths of the files it matches
|
32
|
+
if processed.include?("*")
|
33
|
+
matched_files = Dir.glob(processed)
|
34
|
+
# If no files match, return the pattern itself
|
35
|
+
return processed if matched_files.empty?
|
36
|
+
|
37
|
+
return matched_files.map { |file| File.expand_path(file) }.join("\n")
|
38
|
+
end
|
39
|
+
|
40
|
+
# For tests, if the command was already processed as a shell command and is simple,
|
41
|
+
# don't expand the path to avoid breaking existing tests
|
42
|
+
return processed if target != processed && !processed.include?("/")
|
43
|
+
|
44
|
+
# Don't expand URLs
|
45
|
+
return processed if processed.match?(%r{^https?://})
|
46
|
+
|
47
|
+
# assumed to be a direct file path
|
48
|
+
File.expand_path(processed)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Process shell commands in $(command) or legacy % format
|
52
|
+
# @param command [String] The command string
|
53
|
+
# @return [String] The command output or original string if not a shell command
|
54
|
+
def process_shell_command(command)
|
55
|
+
# If it's a bash command with the $(command) syntax
|
56
|
+
if command =~ /^\$\((.*)\)$/
|
57
|
+
return Open3.capture2e({}, ::Regexp.last_match(1)).first.strip
|
58
|
+
end
|
59
|
+
|
60
|
+
# Legacy % prefix for backward compatibility
|
61
|
+
if command.start_with?("% ")
|
62
|
+
return Open3.capture2e({}, *command.split(" ")[1..-1]).first.strip
|
63
|
+
end
|
64
|
+
|
65
|
+
# Not a shell command, return as is
|
66
|
+
command
|
67
|
+
end
|
68
|
+
|
69
|
+
private
|
70
|
+
|
71
|
+
def has_target?(target)
|
72
|
+
!target.nil? && !target.empty?
|
73
|
+
end
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -7,6 +7,8 @@ module Roast
|
|
7
7
|
module Workflow
|
8
8
|
# Manages session creation, timestamping, and directory management
|
9
9
|
class SessionManager
|
10
|
+
TARGETLESS_FILE_PATH = "notarget"
|
11
|
+
|
10
12
|
def initialize
|
11
13
|
@session_mutex = Mutex.new
|
12
14
|
@session_timestamps = {}
|
@@ -66,9 +68,11 @@ module Roast
|
|
66
68
|
private
|
67
69
|
|
68
70
|
def workflow_directory(session_name, file_path)
|
71
|
+
file_path ||= TARGETLESS_FILE_PATH
|
69
72
|
workflow_dir_name = session_name.parameterize.underscore
|
70
|
-
|
71
|
-
|
73
|
+
# For targetless sessions we don't have a file_path
|
74
|
+
file_id = Digest::MD5.hexdigest(file_path || Dir.pwd)
|
75
|
+
file_basename = File.basename(file_path || Dir.pwd).parameterize.underscore
|
72
76
|
human_readable_id = "#{file_basename}_#{file_id[0..7]}"
|
73
77
|
File.join(Dir.pwd, ".roast", "sessions", workflow_dir_name, human_readable_id)
|
74
78
|
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "roast/workflow/file_state_repository"
|
4
|
+
|
5
|
+
module Roast
|
6
|
+
module Workflow
|
7
|
+
# Manages workflow state persistence and restoration
|
8
|
+
class StateManager
|
9
|
+
attr_reader :workflow, :logger
|
10
|
+
|
11
|
+
def initialize(workflow, logger: nil)
|
12
|
+
@workflow = workflow
|
13
|
+
@logger = logger
|
14
|
+
@state_repository = FileStateRepository.new
|
15
|
+
end
|
16
|
+
|
17
|
+
# Save the current state after a step execution
|
18
|
+
#
|
19
|
+
# @param step_name [String] The name of the step that just completed
|
20
|
+
# @param step_result [Object] The result of the step execution
|
21
|
+
def save_state(step_name, step_result)
|
22
|
+
return unless should_save_state?
|
23
|
+
|
24
|
+
state_data = build_state_data(step_name, step_result)
|
25
|
+
@state_repository.save_state(workflow, step_name, state_data)
|
26
|
+
rescue => e
|
27
|
+
# Don't fail the workflow if state saving fails
|
28
|
+
log_warning("Failed to save workflow state: #{e.message}")
|
29
|
+
end
|
30
|
+
|
31
|
+
# Check if state should be saved for the current workflow
|
32
|
+
#
|
33
|
+
# @return [Boolean] true if state should be saved
|
34
|
+
def should_save_state?
|
35
|
+
workflow.respond_to?(:session_name) && workflow.session_name
|
36
|
+
end
|
37
|
+
|
38
|
+
private
|
39
|
+
|
40
|
+
# Build the state data structure for persistence
|
41
|
+
def build_state_data(step_name, step_result)
|
42
|
+
{
|
43
|
+
step_name: step_name,
|
44
|
+
order: determine_step_order(step_name),
|
45
|
+
transcript: extract_transcript,
|
46
|
+
output: extract_output,
|
47
|
+
final_output: extract_final_output,
|
48
|
+
execution_order: extract_execution_order,
|
49
|
+
}
|
50
|
+
end
|
51
|
+
|
52
|
+
# Determine the order of the step in the workflow
|
53
|
+
def determine_step_order(step_name)
|
54
|
+
return 0 unless workflow.respond_to?(:output)
|
55
|
+
|
56
|
+
workflow.output.keys.index(step_name) || workflow.output.size
|
57
|
+
end
|
58
|
+
|
59
|
+
# Extract transcript data if available
|
60
|
+
def extract_transcript
|
61
|
+
return [] unless workflow.respond_to?(:transcript)
|
62
|
+
|
63
|
+
workflow.transcript.map(&:itself)
|
64
|
+
end
|
65
|
+
|
66
|
+
# Extract output data if available
|
67
|
+
def extract_output
|
68
|
+
return {} unless workflow.respond_to?(:output)
|
69
|
+
|
70
|
+
workflow.output.clone
|
71
|
+
end
|
72
|
+
|
73
|
+
# Extract final output data if available
|
74
|
+
def extract_final_output
|
75
|
+
return [] unless workflow.respond_to?(:final_output)
|
76
|
+
|
77
|
+
workflow.final_output.clone
|
78
|
+
end
|
79
|
+
|
80
|
+
# Extract execution order from workflow output
|
81
|
+
def extract_execution_order
|
82
|
+
return [] unless workflow.respond_to?(:output)
|
83
|
+
|
84
|
+
workflow.output.keys
|
85
|
+
end
|
86
|
+
|
87
|
+
# Log a warning message
|
88
|
+
def log_warning(message)
|
89
|
+
if logger
|
90
|
+
logger.warn(message)
|
91
|
+
else
|
92
|
+
$stderr.puts "WARNING: #{message}"
|
93
|
+
end
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|