roast-ai 0.3.1 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yaml +2 -2
- data/.gitignore +1 -0
- data/CHANGELOG.md +85 -0
- data/CLAUDE.md +106 -9
- data/Gemfile +4 -1
- data/Gemfile.lock +70 -16
- data/README.md +159 -8
- data/bin/console +1 -0
- data/bin/roast +1 -1
- data/claude-swarm.yml +210 -0
- data/docs/AGENT_STEPS.md +288 -0
- data/docs/VALIDATION.md +178 -0
- data/examples/agent_continue/add_documentation/prompt.md +5 -0
- data/examples/agent_continue/add_error_handling/prompt.md +5 -0
- data/examples/agent_continue/analyze_codebase/prompt.md +7 -0
- data/examples/agent_continue/combined_workflow.yml +24 -0
- data/examples/agent_continue/continue_adding_features/prompt.md +4 -0
- data/examples/agent_continue/create_integration_tests/prompt.md +3 -0
- data/examples/agent_continue/document_with_context/prompt.md +5 -0
- data/examples/agent_continue/explore_api/prompt.md +6 -0
- data/examples/agent_continue/implement_client/prompt.md +6 -0
- data/examples/agent_continue/inline_workflow.yml +20 -0
- data/examples/agent_continue/refactor_code/prompt.md +2 -0
- data/examples/agent_continue/verify_changes/prompt.md +6 -0
- data/examples/agent_continue/workflow.yml +27 -0
- data/examples/agent_workflow/README.md +75 -0
- data/examples/agent_workflow/apply_refactorings/prompt.md +22 -0
- data/examples/agent_workflow/identify_code_smells/prompt.md +15 -0
- data/examples/agent_workflow/summarize_improvements/prompt.md +18 -0
- data/examples/agent_workflow/workflow.png +0 -0
- data/examples/agent_workflow/workflow.yml +16 -0
- data/examples/api_workflow/workflow.png +0 -0
- data/examples/apply_diff_demo/README.md +58 -0
- data/examples/apply_diff_demo/apply_simple_change/prompt.md +13 -0
- data/examples/apply_diff_demo/create_sample_file/prompt.md +11 -0
- data/examples/apply_diff_demo/workflow.yml +24 -0
- data/examples/available_tools_demo/README.md +42 -0
- data/examples/available_tools_demo/analyze_files/prompt.md +6 -0
- data/examples/available_tools_demo/explore_directory/prompt.md +6 -0
- data/examples/available_tools_demo/workflow.png +0 -0
- data/examples/available_tools_demo/workflow.yml +32 -0
- data/examples/available_tools_demo/write_summary/prompt.md +6 -0
- data/examples/bash_prototyping/api_testing.png +0 -0
- data/examples/bash_prototyping/system_analysis.png +0 -0
- data/examples/case_when/detect_language/prompt.md +2 -2
- data/examples/case_when/workflow.png +0 -0
- data/examples/cmd/basic_workflow.png +0 -0
- data/examples/cmd/dev_workflow.png +0 -0
- data/examples/cmd/explorer_workflow.png +0 -0
- data/examples/conditional/simple_workflow.png +0 -0
- data/examples/conditional/workflow.png +0 -0
- data/examples/context_management_demo/README.md +43 -0
- data/examples/context_management_demo/workflow.yml +42 -0
- data/examples/direct_coerce_syntax/workflow.png +0 -0
- data/examples/dot_notation/workflow.png +0 -0
- data/examples/exit_on_error/workflow.png +0 -0
- data/examples/grading/run_coverage.rb +0 -2
- data/examples/grading/workflow.png +0 -0
- data/examples/interpolation/workflow.png +0 -0
- data/examples/interpolation/workflow.yml +1 -1
- data/examples/iteration/analyze_complexity/prompt.md +2 -2
- data/examples/iteration/generate_recommendations/prompt.md +2 -2
- data/examples/iteration/implement_fix/prompt.md +2 -2
- data/examples/iteration/prioritize_issues/prompt.md +1 -1
- data/examples/iteration/prompts/analyze_file.md +2 -2
- data/examples/iteration/prompts/generate_summary.md +1 -1
- data/examples/iteration/prompts/update_report.md +3 -3
- data/examples/iteration/prompts/write_report.md +3 -3
- data/examples/iteration/read_file/prompt.md +2 -2
- data/examples/iteration/select_next_issue/prompt.md +2 -2
- data/examples/iteration/update_fix_count/prompt.md +4 -4
- data/examples/iteration/verify_fix/prompt.md +3 -3
- data/examples/iteration/workflow.png +0 -0
- data/examples/json_handling/workflow.png +0 -0
- data/examples/mcp/README.md +3 -3
- data/examples/mcp/analyze_changes/prompt.md +1 -1
- data/examples/mcp/database_workflow.png +0 -0
- data/examples/mcp/database_workflow.yml +1 -1
- data/examples/mcp/env_demo/workflow.png +0 -0
- data/examples/mcp/fetch_pr_context/prompt.md +1 -1
- data/examples/mcp/filesystem_demo/workflow.png +0 -0
- data/examples/mcp/github_workflow.png +0 -0
- data/examples/mcp/github_workflow.yml +1 -1
- data/examples/mcp/multi_mcp_workflow.png +0 -0
- data/examples/mcp/post_review/prompt.md +1 -1
- data/examples/mcp/workflow.png +0 -0
- data/examples/no_model_fallback/README.md +17 -0
- data/examples/no_model_fallback/analyze_file/prompt.md +1 -0
- data/examples/no_model_fallback/analyze_patterns/prompt.md +27 -0
- data/examples/no_model_fallback/generate_report_for_md/prompt.md +10 -0
- data/examples/no_model_fallback/generate_report_for_rb/prompt.md +3 -0
- data/examples/no_model_fallback/sample.rb +42 -0
- data/examples/no_model_fallback/workflow.yml +19 -0
- data/examples/openrouter_example/workflow.png +0 -0
- data/examples/pre_post_processing/analyze_test_file/prompt.md +1 -1
- data/examples/pre_post_processing/improve_test_coverage/prompt.md +1 -1
- data/examples/pre_post_processing/optimize_test_performance/prompt.md +1 -1
- data/examples/pre_post_processing/post_processing/aggregate_metrics/prompt.md +2 -2
- data/examples/pre_post_processing/post_processing/generate_summary_report/prompt.md +1 -1
- data/examples/pre_post_processing/pre_processing/setup_test_environment/prompt.md +1 -1
- data/examples/pre_post_processing/validate_changes/prompt.md +2 -2
- data/examples/pre_post_processing/workflow.png +0 -0
- data/examples/rspec_to_minitest/workflow.png +0 -0
- data/examples/shared_config/example_with_shared_config/workflow.png +0 -0
- data/examples/shared_config/shared.png +0 -0
- data/examples/single_target_prepost/workflow.png +0 -0
- data/examples/smart_coercion_defaults/workflow.png +0 -0
- data/examples/step_configuration/workflow.png +0 -0
- data/examples/swarm_example.yml +25 -0
- data/examples/tool_config_example/workflow.png +0 -0
- data/examples/user_input/README.md +90 -0
- data/examples/user_input/funny_name/create_backstory/prompt.md +10 -0
- data/examples/user_input/funny_name/workflow.png +0 -0
- data/examples/user_input/funny_name/workflow.yml +26 -0
- data/examples/user_input/generate_summary/prompt.md +11 -0
- data/examples/user_input/simple_input_demo/workflow.png +0 -0
- data/examples/user_input/simple_input_demo/workflow.yml +35 -0
- data/examples/user_input/survey_workflow.png +0 -0
- data/examples/user_input/survey_workflow.yml +71 -0
- data/examples/user_input/welcome_message/prompt.md +3 -0
- data/examples/user_input/workflow.png +0 -0
- data/examples/user_input/workflow.yml +73 -0
- data/examples/workflow_generator/create_workflow_files/prompt.md +1 -1
- data/examples/workflow_generator/workflow.png +0 -0
- data/lib/roast/errors.rb +6 -4
- data/lib/roast/helpers/function_caching_interceptor.rb +0 -2
- data/lib/roast/helpers/logger.rb +12 -35
- data/lib/roast/helpers/minitest_coverage_runner.rb +0 -1
- data/lib/roast/helpers/prompt_loader.rb +0 -2
- data/lib/roast/helpers/timeout_handler.rb +91 -0
- data/lib/roast/resources/api_resource.rb +0 -4
- data/lib/roast/resources/url_resource.rb +0 -3
- data/lib/roast/resources.rb +0 -8
- data/lib/roast/services/context_threshold_checker.rb +42 -0
- data/lib/roast/services/token_counting_service.rb +44 -0
- data/lib/roast/tools/apply_diff.rb +128 -0
- data/lib/roast/tools/ask_user.rb +0 -2
- data/lib/roast/tools/bash.rb +12 -9
- data/lib/roast/tools/cmd.rb +29 -12
- data/lib/roast/tools/coding_agent.rb +65 -17
- data/lib/roast/tools/context_summarizer.rb +108 -0
- data/lib/roast/tools/grep.rb +0 -3
- data/lib/roast/tools/helpers/coding_agent_message_formatter.rb +1 -4
- data/lib/roast/tools/read_file.rb +0 -2
- data/lib/roast/tools/search_file.rb +0 -2
- data/lib/roast/tools/swarm.rb +124 -0
- data/lib/roast/tools/update_files.rb +0 -4
- data/lib/roast/tools/write_file.rb +0 -3
- data/lib/roast/tools.rb +0 -13
- data/lib/roast/value_objects/step_name.rb +14 -3
- data/lib/roast/value_objects/workflow_path.rb +0 -2
- data/lib/roast/value_objects.rb +4 -4
- data/lib/roast/version.rb +1 -1
- data/lib/roast/workflow/agent_step.rb +33 -0
- data/lib/roast/workflow/api_configuration.rb +0 -4
- data/lib/roast/workflow/base_iteration_step.rb +3 -6
- data/lib/roast/workflow/base_step.rb +54 -28
- data/lib/roast/workflow/base_workflow.rb +43 -23
- data/lib/roast/workflow/case_executor.rb +0 -1
- data/lib/roast/workflow/case_step.rb +0 -4
- data/lib/roast/workflow/command_executor.rb +0 -2
- data/lib/roast/workflow/conditional_executor.rb +0 -1
- data/lib/roast/workflow/conditional_step.rb +0 -4
- data/lib/roast/workflow/configuration.rb +5 -67
- data/lib/roast/workflow/configuration_loader.rb +63 -3
- data/lib/roast/workflow/configuration_parser.rb +1 -7
- data/lib/roast/workflow/context_manager.rb +89 -0
- data/lib/roast/workflow/dot_access_hash.rb +16 -1
- data/lib/roast/workflow/each_step.rb +1 -1
- data/lib/roast/workflow/error_handler.rb +0 -3
- data/lib/roast/workflow/expression_evaluator.rb +0 -3
- data/lib/roast/workflow/file_state_repository.rb +0 -5
- data/lib/roast/workflow/input_executor.rb +41 -0
- data/lib/roast/workflow/input_step.rb +163 -0
- data/lib/roast/workflow/iteration_executor.rb +0 -2
- data/lib/roast/workflow/output_handler.rb +1 -3
- data/lib/roast/workflow/output_manager.rb +0 -2
- data/lib/roast/workflow/repeat_step.rb +1 -1
- data/lib/roast/workflow/replay_handler.rb +1 -4
- data/lib/roast/workflow/resource_resolver.rb +0 -3
- data/lib/roast/workflow/session_manager.rb +0 -3
- data/lib/roast/workflow/sqlite_state_repository.rb +342 -0
- data/lib/roast/workflow/state_manager.rb +2 -4
- data/lib/roast/workflow/state_repository_factory.rb +36 -0
- data/lib/roast/workflow/step_completion_reporter.rb +27 -0
- data/lib/roast/workflow/step_executor_coordinator.rb +48 -24
- data/lib/roast/workflow/step_executor_factory.rb +0 -5
- data/lib/roast/workflow/step_executor_registry.rb +1 -4
- data/lib/roast/workflow/step_executor_with_reporting.rb +68 -0
- data/lib/roast/workflow/step_executors/hash_step_executor.rb +0 -3
- data/lib/roast/workflow/step_executors/parallel_step_executor.rb +0 -3
- data/lib/roast/workflow/step_executors/string_step_executor.rb +0 -2
- data/lib/roast/workflow/step_factory.rb +56 -0
- data/lib/roast/workflow/step_loader.rb +31 -17
- data/lib/roast/workflow/step_name_extractor.rb +84 -0
- data/lib/roast/workflow/step_orchestrator.rb +3 -2
- data/lib/roast/workflow/step_type_resolver.rb +28 -1
- data/lib/roast/workflow/validation_command.rb +197 -0
- data/lib/roast/workflow/validator.rb +0 -4
- data/lib/roast/workflow/validators/base_validator.rb +44 -0
- data/lib/roast/workflow/validators/dependency_validator.rb +223 -0
- data/lib/roast/workflow/validators/linting_validator.rb +113 -0
- data/lib/roast/workflow/validators/schema_validator.rb +90 -0
- data/lib/roast/workflow/validators/step_collector.rb +57 -0
- data/lib/roast/workflow/validators/validation_orchestrator.rb +52 -0
- data/lib/roast/workflow/workflow_executor.rb +11 -20
- data/lib/roast/workflow/workflow_initializer.rb +1 -8
- data/lib/roast/workflow/workflow_runner.rb +6 -7
- data/lib/roast/workflow.rb +0 -15
- data/lib/roast/workflow_diagram_generator.rb +298 -0
- data/lib/roast.rb +212 -10
- data/roast.gemspec +4 -2
- data/schema/workflow.json +123 -1
- metadata +143 -6
- data/lib/roast/helpers.rb +0 -12
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
# Handles execution of input steps
|
6
|
+
class InputExecutor
|
7
|
+
def initialize(workflow, context_path, state_manager, workflow_executor = nil)
|
8
|
+
@workflow = workflow
|
9
|
+
@context_path = context_path
|
10
|
+
@state_manager = state_manager
|
11
|
+
@workflow_executor = workflow_executor
|
12
|
+
end
|
13
|
+
|
14
|
+
def execute_input(input_config)
|
15
|
+
# Interpolate the prompt if workflow executor is available
|
16
|
+
if @workflow_executor && input_config["prompt"]
|
17
|
+
interpolated_config = input_config.dup
|
18
|
+
interpolated_config["prompt"] = @workflow_executor.interpolate(input_config["prompt"])
|
19
|
+
else
|
20
|
+
interpolated_config = input_config
|
21
|
+
end
|
22
|
+
|
23
|
+
# Create and execute an InputStep
|
24
|
+
input_step = InputStep.new(
|
25
|
+
@workflow,
|
26
|
+
config: interpolated_config,
|
27
|
+
name: input_config["name"] || "input_#{Time.now.to_i}",
|
28
|
+
context_path: @context_path,
|
29
|
+
)
|
30
|
+
|
31
|
+
result = input_step.call
|
32
|
+
|
33
|
+
# Store in 'previous' for conditional checks
|
34
|
+
@workflow.output["previous"] = result
|
35
|
+
@state_manager.save_state("previous", result)
|
36
|
+
|
37
|
+
result
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -0,0 +1,163 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "timeout"
|
4
|
+
|
5
|
+
module Roast
|
6
|
+
module Workflow
|
7
|
+
class InputStep < BaseStep
|
8
|
+
attr_reader :prompt_text, :type, :required, :default, :timeout, :options, :step_name
|
9
|
+
|
10
|
+
def initialize(workflow, config:, **kwargs)
|
11
|
+
super(workflow, **kwargs)
|
12
|
+
parse_config(config)
|
13
|
+
end
|
14
|
+
|
15
|
+
def call
|
16
|
+
# Get user input based on the configured type
|
17
|
+
result = case type
|
18
|
+
when "boolean"
|
19
|
+
prompt_boolean
|
20
|
+
when "choice"
|
21
|
+
prompt_choice
|
22
|
+
when "password"
|
23
|
+
prompt_password
|
24
|
+
else
|
25
|
+
prompt_text_input
|
26
|
+
end
|
27
|
+
|
28
|
+
# Store the result in workflow state if a name was provided
|
29
|
+
store_in_state(result) if step_name
|
30
|
+
|
31
|
+
result
|
32
|
+
rescue Timeout::Error
|
33
|
+
handle_timeout
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def parse_config(config)
|
39
|
+
@prompt_text = config["prompt"] || raise_config_error("Missing 'prompt' in input configuration")
|
40
|
+
@step_name = config["name"]
|
41
|
+
@type = config["type"] || "text"
|
42
|
+
@required = config.fetch("required", false)
|
43
|
+
@default = config["default"]
|
44
|
+
@timeout = config["timeout"]
|
45
|
+
@options = config["options"]
|
46
|
+
|
47
|
+
validate_config
|
48
|
+
end
|
49
|
+
|
50
|
+
def validate_config
|
51
|
+
if type == "choice" && options.nil?
|
52
|
+
raise_config_error("Missing 'options' for choice type input")
|
53
|
+
end
|
54
|
+
|
55
|
+
if type == "boolean" && default && ![true, false, "true", "false", "yes", "no"].include?(default)
|
56
|
+
raise_config_error("Invalid default value for boolean type: #{default}")
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def prompt_text_input
|
61
|
+
loop do
|
62
|
+
result = if timeout
|
63
|
+
with_timeout { ::CLI::UI.ask(prompt_text, default: default) }
|
64
|
+
else
|
65
|
+
::CLI::UI.ask(prompt_text, default: default)
|
66
|
+
end
|
67
|
+
|
68
|
+
if required && result.to_s.strip.empty?
|
69
|
+
puts ::CLI::UI.fmt("{{red:This field is required. Please provide a value.}}")
|
70
|
+
next
|
71
|
+
end
|
72
|
+
|
73
|
+
return result
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def prompt_boolean
|
78
|
+
if timeout
|
79
|
+
with_timeout { ::CLI::UI.confirm(prompt_text, default: boolean_default) }
|
80
|
+
else
|
81
|
+
::CLI::UI.confirm(prompt_text, default: boolean_default)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
def prompt_choice
|
86
|
+
if timeout
|
87
|
+
with_timeout { ::CLI::UI.ask(prompt_text, options: options, default: default) }
|
88
|
+
else
|
89
|
+
::CLI::UI.ask(prompt_text, options: options, default: default)
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def prompt_password
|
94
|
+
require "io/console"
|
95
|
+
|
96
|
+
loop do
|
97
|
+
result = if timeout
|
98
|
+
with_timeout { prompt_password_with_echo_off }
|
99
|
+
else
|
100
|
+
prompt_password_with_echo_off
|
101
|
+
end
|
102
|
+
|
103
|
+
if required && result.to_s.strip.empty?
|
104
|
+
puts ::CLI::UI.fmt("{{red:This field is required. Please provide a value.}}")
|
105
|
+
next
|
106
|
+
end
|
107
|
+
|
108
|
+
return result
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
def prompt_password_with_echo_off
|
113
|
+
::CLI::UI.with_frame_color(:blue) do
|
114
|
+
print("🔒 #{prompt_text} ")
|
115
|
+
|
116
|
+
password = if $stdin.tty?
|
117
|
+
# Use noecho for TTY environments
|
118
|
+
$stdin.noecho { $stdin.gets }.chomp
|
119
|
+
else
|
120
|
+
# Fall back to regular input for non-TTY environments
|
121
|
+
warn("[WARNING] Password will be visible (not running in TTY)")
|
122
|
+
$stdin.gets.chomp
|
123
|
+
end
|
124
|
+
|
125
|
+
puts # Add newline after password input
|
126
|
+
password
|
127
|
+
end
|
128
|
+
end
|
129
|
+
|
130
|
+
def boolean_default
|
131
|
+
case default
|
132
|
+
when true, "true", "yes"
|
133
|
+
true
|
134
|
+
when false, "false", "no"
|
135
|
+
false
|
136
|
+
end
|
137
|
+
end
|
138
|
+
|
139
|
+
def with_timeout(&block)
|
140
|
+
Timeout.timeout(timeout, &block)
|
141
|
+
end
|
142
|
+
|
143
|
+
def handle_timeout
|
144
|
+
puts ::CLI::UI.fmt("{{yellow:Input timed out after #{timeout} seconds}}")
|
145
|
+
|
146
|
+
if default
|
147
|
+
puts ::CLI::UI.fmt("{{yellow:Using default value: #{default}}}")
|
148
|
+
default
|
149
|
+
elsif required
|
150
|
+
raise_config_error("Required input timed out with no default value")
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
def store_in_state(value)
|
155
|
+
workflow.output[step_name] = value
|
156
|
+
end
|
157
|
+
|
158
|
+
def raise_config_error(message)
|
159
|
+
raise WorkflowExecutor::ConfigurationError, message
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
@@ -24,7 +24,6 @@ module Roast
|
|
24
24
|
raise WorkflowExecutor::ConfigurationError, "Missing 'until' condition in repeat configuration" unless until_condition
|
25
25
|
|
26
26
|
# Create and execute a RepeatStep
|
27
|
-
require "roast/workflow/repeat_step" unless defined?(RepeatStep)
|
28
27
|
repeat_step = RepeatStep.new(
|
29
28
|
@workflow,
|
30
29
|
steps: steps,
|
@@ -64,7 +63,6 @@ module Roast
|
|
64
63
|
raise WorkflowExecutor::ConfigurationError, "Missing 'steps' in each configuration" unless steps
|
65
64
|
|
66
65
|
# Create and execute an EachStep
|
67
|
-
require "roast/workflow/each_step" unless defined?(EachStep)
|
68
66
|
each_step = EachStep.new(
|
69
67
|
@workflow,
|
70
68
|
collection_expr: collection_expr,
|
@@ -1,7 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "roast/workflow/file_state_repository"
|
4
|
-
|
5
3
|
module Roast
|
6
4
|
module Workflow
|
7
5
|
# Handles output operations for workflows including saving final output and results
|
@@ -13,7 +11,7 @@ module Roast
|
|
13
11
|
final_output = workflow.final_output.to_s
|
14
12
|
return if final_output.empty?
|
15
13
|
|
16
|
-
state_repository =
|
14
|
+
state_repository = StateRepositoryFactory.create(workflow.storage_type)
|
17
15
|
output_file = state_repository.save_final_output(workflow, final_output)
|
18
16
|
$stderr.puts "Final output saved to: #{output_file}" if output_file
|
19
17
|
rescue => e
|
@@ -1,8 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "roast/workflow/step_finder"
|
4
|
-
require "roast/workflow/file_state_repository"
|
5
|
-
|
6
3
|
module Roast
|
7
4
|
module Workflow
|
8
5
|
# Handles replay functionality for workflows
|
@@ -12,7 +9,7 @@ module Roast
|
|
12
9
|
|
13
10
|
def initialize(workflow, state_repository: nil)
|
14
11
|
@workflow = workflow
|
15
|
-
@state_repository = state_repository ||
|
12
|
+
@state_repository = state_repository || StateRepositoryFactory.create(workflow.storage_type)
|
16
13
|
@processed = false
|
17
14
|
end
|
18
15
|
|
@@ -0,0 +1,342 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
|
5
|
+
module Roast
|
6
|
+
module Workflow
|
7
|
+
# SQLite-based implementation of StateRepository
|
8
|
+
# Provides structured, queryable session storage with better performance
|
9
|
+
class SqliteStateRepository < StateRepository
|
10
|
+
DEFAULT_DB_PATH = File.expand_path("~/.roast/sessions.db")
|
11
|
+
|
12
|
+
def initialize(db_path: nil, session_manager: SessionManager.new)
|
13
|
+
super()
|
14
|
+
|
15
|
+
# Lazy load sqlite3 only when actually using SQLite storage
|
16
|
+
begin
|
17
|
+
require "sqlite3"
|
18
|
+
rescue LoadError
|
19
|
+
raise LoadError, "SQLite storage requires the 'sqlite3' gem. Please add it to your Gemfile or install it: gem install sqlite3"
|
20
|
+
end
|
21
|
+
|
22
|
+
@db_path = db_path || ENV["ROAST_SESSIONS_DB"] || DEFAULT_DB_PATH
|
23
|
+
@session_manager = session_manager
|
24
|
+
ensure_database
|
25
|
+
end
|
26
|
+
|
27
|
+
def save_state(workflow, step_name, state_data)
|
28
|
+
workflow.session_timestamp ||= @session_manager.create_new_session(workflow.object_id)
|
29
|
+
|
30
|
+
session_id = ensure_session(workflow)
|
31
|
+
|
32
|
+
@db.execute(<<~SQL, session_id, state_data[:order], step_name, state_data.to_json)
|
33
|
+
INSERT INTO session_states (session_id, step_index, step_name, state_data)
|
34
|
+
VALUES (?, ?, ?, ?)
|
35
|
+
SQL
|
36
|
+
|
37
|
+
# Update session's current step
|
38
|
+
@db.execute(<<~SQL, state_data[:order], session_id)
|
39
|
+
UPDATE sessions#{" "}
|
40
|
+
SET current_step_index = ?, updated_at = CURRENT_TIMESTAMP
|
41
|
+
WHERE id = ?
|
42
|
+
SQL
|
43
|
+
rescue => e
|
44
|
+
$stderr.puts "Failed to save state for step #{step_name}: #{e.message}"
|
45
|
+
end
|
46
|
+
|
47
|
+
def load_state_before_step(workflow, step_name, timestamp: nil)
|
48
|
+
session_id = find_session_id(workflow, timestamp)
|
49
|
+
return false unless session_id
|
50
|
+
|
51
|
+
# Find the state before the target step
|
52
|
+
result = @db.execute(<<~SQL, session_id, step_name)
|
53
|
+
SELECT state_data, step_name
|
54
|
+
FROM session_states
|
55
|
+
WHERE session_id = ?
|
56
|
+
AND step_index < (
|
57
|
+
SELECT MIN(step_index)#{" "}
|
58
|
+
FROM session_states#{" "}
|
59
|
+
WHERE session_id = ? AND step_name = ?
|
60
|
+
)
|
61
|
+
ORDER BY step_index DESC
|
62
|
+
LIMIT 1
|
63
|
+
SQL
|
64
|
+
|
65
|
+
if result.empty?
|
66
|
+
# Try to find the latest state if target step doesn't exist
|
67
|
+
result = @db.execute(<<~SQL, session_id)
|
68
|
+
SELECT state_data, step_name
|
69
|
+
FROM session_states
|
70
|
+
WHERE session_id = ?
|
71
|
+
ORDER BY step_index DESC
|
72
|
+
LIMIT 1
|
73
|
+
SQL
|
74
|
+
|
75
|
+
if result.empty?
|
76
|
+
$stderr.puts "No state found for session"
|
77
|
+
return false
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
state_data = JSON.parse(result[0][0], symbolize_names: true)
|
82
|
+
loaded_step = result[0][1]
|
83
|
+
$stderr.puts "Found state from step: #{loaded_step} (will replay from here to #{step_name})"
|
84
|
+
|
85
|
+
# If no timestamp provided and workflow has no session, create new session and copy states
|
86
|
+
if !timestamp && workflow.session_timestamp.nil?
|
87
|
+
copy_states_to_new_session(workflow, session_id, step_name)
|
88
|
+
end
|
89
|
+
|
90
|
+
state_data
|
91
|
+
end
|
92
|
+
|
93
|
+
def save_final_output(workflow, output_content)
|
94
|
+
return if output_content.empty?
|
95
|
+
|
96
|
+
session_id = ensure_session(workflow)
|
97
|
+
|
98
|
+
@db.execute(<<~SQL, output_content, session_id)
|
99
|
+
UPDATE sessions#{" "}
|
100
|
+
SET final_output = ?, status = 'completed', updated_at = CURRENT_TIMESTAMP
|
101
|
+
WHERE id = ?
|
102
|
+
SQL
|
103
|
+
|
104
|
+
session_id
|
105
|
+
rescue => e
|
106
|
+
$stderr.puts "Failed to save final output: #{e.message}"
|
107
|
+
nil
|
108
|
+
end
|
109
|
+
|
110
|
+
# Additional query methods for the new capabilities
|
111
|
+
|
112
|
+
def list_sessions(status: nil, workflow_name: nil, older_than: nil, limit: 100)
|
113
|
+
conditions = []
|
114
|
+
params = []
|
115
|
+
|
116
|
+
if status
|
117
|
+
conditions << "status = ?"
|
118
|
+
params << status
|
119
|
+
end
|
120
|
+
|
121
|
+
if workflow_name
|
122
|
+
conditions << "workflow_name = ?"
|
123
|
+
params << workflow_name
|
124
|
+
end
|
125
|
+
|
126
|
+
if older_than
|
127
|
+
conditions << "created_at < datetime('now', ?)"
|
128
|
+
params << "-#{older_than}"
|
129
|
+
end
|
130
|
+
|
131
|
+
where_clause = conditions.empty? ? "" : "WHERE #{conditions.join(" AND ")}"
|
132
|
+
|
133
|
+
@db.execute(<<~SQL, *params)
|
134
|
+
SELECT id, workflow_name, workflow_path, status, current_step_index,#{" "}
|
135
|
+
created_at, updated_at
|
136
|
+
FROM sessions
|
137
|
+
#{where_clause}
|
138
|
+
ORDER BY created_at DESC
|
139
|
+
LIMIT #{limit}
|
140
|
+
SQL
|
141
|
+
end
|
142
|
+
|
143
|
+
def get_session_details(session_id)
|
144
|
+
session = @db.execute(<<~SQL, session_id).first
|
145
|
+
SELECT * FROM sessions WHERE id = ?
|
146
|
+
SQL
|
147
|
+
|
148
|
+
return unless session
|
149
|
+
|
150
|
+
states = @db.execute(<<~SQL, session_id)
|
151
|
+
SELECT step_index, step_name, created_at
|
152
|
+
FROM session_states
|
153
|
+
WHERE session_id = ?
|
154
|
+
ORDER BY step_index
|
155
|
+
SQL
|
156
|
+
|
157
|
+
events = @db.execute(<<~SQL, session_id)
|
158
|
+
SELECT event_name, event_data, received_at
|
159
|
+
FROM session_events
|
160
|
+
WHERE session_id = ?
|
161
|
+
ORDER BY received_at
|
162
|
+
SQL
|
163
|
+
|
164
|
+
{
|
165
|
+
session: session,
|
166
|
+
states: states,
|
167
|
+
events: events,
|
168
|
+
}
|
169
|
+
end
|
170
|
+
|
171
|
+
def cleanup_old_sessions(older_than)
|
172
|
+
count = @db.changes
|
173
|
+
@db.execute(<<~SQL, "-#{older_than}")
|
174
|
+
DELETE FROM sessions
|
175
|
+
WHERE created_at < datetime('now', ?)
|
176
|
+
SQL
|
177
|
+
@db.changes - count
|
178
|
+
end
|
179
|
+
|
180
|
+
def add_event(workflow_path, session_id, event_name, event_data = nil)
|
181
|
+
# Find the session if session_id not provided
|
182
|
+
unless session_id
|
183
|
+
workflow_name = File.basename(File.dirname(workflow_path))
|
184
|
+
result = @db.execute(<<~SQL, workflow_name, "waiting")
|
185
|
+
SELECT id FROM sessions
|
186
|
+
WHERE workflow_name = ? AND status = ?
|
187
|
+
ORDER BY created_at DESC
|
188
|
+
LIMIT 1
|
189
|
+
SQL
|
190
|
+
|
191
|
+
raise "No waiting session found for workflow: #{workflow_name}" if result.empty?
|
192
|
+
|
193
|
+
session_id = result[0][0]
|
194
|
+
end
|
195
|
+
|
196
|
+
# Add the event
|
197
|
+
@db.execute(<<~SQL, session_id, event_name, event_data&.to_json)
|
198
|
+
INSERT INTO session_events (session_id, event_name, event_data)
|
199
|
+
VALUES (?, ?, ?)
|
200
|
+
SQL
|
201
|
+
|
202
|
+
# Update session status
|
203
|
+
@db.execute(<<~SQL, session_id)
|
204
|
+
UPDATE sessions#{" "}
|
205
|
+
SET status = 'running', updated_at = CURRENT_TIMESTAMP
|
206
|
+
WHERE id = ?
|
207
|
+
SQL
|
208
|
+
|
209
|
+
session_id
|
210
|
+
end
|
211
|
+
|
212
|
+
private
|
213
|
+
|
214
|
+
def ensure_database
|
215
|
+
FileUtils.mkdir_p(File.dirname(@db_path))
|
216
|
+
@db = SQLite3::Database.new(@db_path)
|
217
|
+
@db.execute("PRAGMA foreign_keys = ON")
|
218
|
+
create_schema
|
219
|
+
end
|
220
|
+
|
221
|
+
def create_schema
|
222
|
+
@db.execute_batch(<<~SQL)
|
223
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
224
|
+
id TEXT PRIMARY KEY,
|
225
|
+
workflow_name TEXT NOT NULL,
|
226
|
+
workflow_path TEXT NOT NULL,
|
227
|
+
status TEXT NOT NULL DEFAULT 'running',
|
228
|
+
current_step_index INTEGER,
|
229
|
+
final_output TEXT,
|
230
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
231
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
232
|
+
);
|
233
|
+
|
234
|
+
CREATE TABLE IF NOT EXISTS session_states (
|
235
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
236
|
+
session_id TEXT NOT NULL,
|
237
|
+
step_index INTEGER NOT NULL,
|
238
|
+
step_name TEXT NOT NULL,
|
239
|
+
state_data TEXT NOT NULL,
|
240
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
241
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
242
|
+
);
|
243
|
+
|
244
|
+
CREATE TABLE IF NOT EXISTS session_events (
|
245
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
246
|
+
session_id TEXT NOT NULL,
|
247
|
+
event_name TEXT NOT NULL,
|
248
|
+
event_data TEXT,
|
249
|
+
received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
250
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
251
|
+
);
|
252
|
+
|
253
|
+
CREATE TABLE IF NOT EXISTS session_variables (
|
254
|
+
session_id TEXT NOT NULL,
|
255
|
+
key TEXT NOT NULL,
|
256
|
+
value TEXT NOT NULL,
|
257
|
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
258
|
+
PRIMARY KEY (session_id, key),
|
259
|
+
FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
|
260
|
+
);
|
261
|
+
|
262
|
+
-- Indexes for common queries
|
263
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
|
264
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_workflow_name ON sessions(workflow_name);
|
265
|
+
CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at);
|
266
|
+
CREATE INDEX IF NOT EXISTS idx_session_states_session_id ON session_states(session_id);
|
267
|
+
CREATE INDEX IF NOT EXISTS idx_session_events_session_id ON session_events(session_id);
|
268
|
+
SQL
|
269
|
+
end
|
270
|
+
|
271
|
+
def ensure_session(workflow)
|
272
|
+
session_id = generate_session_id(workflow)
|
273
|
+
|
274
|
+
# Check if session exists
|
275
|
+
existing = @db.execute("SELECT id FROM sessions WHERE id = ?", session_id).first
|
276
|
+
return session_id if existing
|
277
|
+
|
278
|
+
# Create new session
|
279
|
+
workflow_name = workflow.session_name || "unnamed"
|
280
|
+
workflow_path = workflow.file || "notarget"
|
281
|
+
|
282
|
+
@db.execute(<<~SQL, session_id, workflow_name, workflow_path)
|
283
|
+
INSERT INTO sessions (id, workflow_name, workflow_path)
|
284
|
+
VALUES (?, ?, ?)
|
285
|
+
SQL
|
286
|
+
|
287
|
+
session_id
|
288
|
+
end
|
289
|
+
|
290
|
+
def find_session_id(workflow, timestamp)
|
291
|
+
if timestamp
|
292
|
+
# Find by exact timestamp
|
293
|
+
generate_session_id(workflow, timestamp)
|
294
|
+
else
|
295
|
+
# Find latest session for this workflow
|
296
|
+
workflow_name = workflow.session_name || "unnamed"
|
297
|
+
workflow_path = workflow.file || "notarget"
|
298
|
+
|
299
|
+
result = @db.execute(<<~SQL, workflow_name, workflow_path)
|
300
|
+
SELECT id FROM sessions
|
301
|
+
WHERE workflow_name = ? AND workflow_path = ?
|
302
|
+
ORDER BY created_at DESC
|
303
|
+
LIMIT 1
|
304
|
+
SQL
|
305
|
+
|
306
|
+
result.empty? ? nil : result[0][0]
|
307
|
+
end
|
308
|
+
end
|
309
|
+
|
310
|
+
def generate_session_id(workflow, timestamp = nil)
|
311
|
+
timestamp ||= workflow.session_timestamp || @session_manager.create_new_session(workflow.object_id)
|
312
|
+
workflow_name = workflow.session_name || "unnamed"
|
313
|
+
workflow_path = workflow.file || "notarget"
|
314
|
+
|
315
|
+
# Generate a unique session ID based on workflow info and timestamp
|
316
|
+
file_hash = Digest::MD5.hexdigest(workflow_path)[0..7]
|
317
|
+
"#{workflow_name.parameterize.underscore}_#{file_hash}_#{timestamp}"
|
318
|
+
end
|
319
|
+
|
320
|
+
def copy_states_to_new_session(workflow, source_session_id, target_step_name)
|
321
|
+
# Create new session
|
322
|
+
new_timestamp = @session_manager.create_new_session(workflow.object_id)
|
323
|
+
workflow.session_timestamp = new_timestamp
|
324
|
+
new_session_id = ensure_session(workflow)
|
325
|
+
|
326
|
+
# Copy states up to the target step
|
327
|
+
@db.execute(<<~SQL, new_session_id, source_session_id, target_step_name, source_session_id)
|
328
|
+
INSERT INTO session_states (session_id, step_index, step_name, state_data)
|
329
|
+
SELECT ?, step_index, step_name, state_data
|
330
|
+
FROM session_states
|
331
|
+
WHERE session_id = ?
|
332
|
+
AND step_index < COALESCE(
|
333
|
+
(SELECT MIN(step_index) FROM session_states WHERE session_id = ? AND step_name = ?),
|
334
|
+
999999
|
335
|
+
)
|
336
|
+
SQL
|
337
|
+
|
338
|
+
true
|
339
|
+
end
|
340
|
+
end
|
341
|
+
end
|
342
|
+
end
|
@@ -1,17 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "roast/workflow/file_state_repository"
|
4
|
-
|
5
3
|
module Roast
|
6
4
|
module Workflow
|
7
5
|
# Manages workflow state persistence and restoration
|
8
6
|
class StateManager
|
9
7
|
attr_reader :workflow, :logger
|
10
8
|
|
11
|
-
def initialize(workflow, logger: nil)
|
9
|
+
def initialize(workflow, logger: nil, state_repository: nil, storage_type: nil)
|
12
10
|
@workflow = workflow
|
13
11
|
@logger = logger
|
14
|
-
@state_repository =
|
12
|
+
@state_repository = state_repository || StateRepositoryFactory.create(storage_type)
|
15
13
|
end
|
16
14
|
|
17
15
|
# Save the current state after a step execution
|