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.
Files changed (216) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +2 -2
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +85 -0
  5. data/CLAUDE.md +106 -9
  6. data/Gemfile +4 -1
  7. data/Gemfile.lock +70 -16
  8. data/README.md +159 -8
  9. data/bin/console +1 -0
  10. data/bin/roast +1 -1
  11. data/claude-swarm.yml +210 -0
  12. data/docs/AGENT_STEPS.md +288 -0
  13. data/docs/VALIDATION.md +178 -0
  14. data/examples/agent_continue/add_documentation/prompt.md +5 -0
  15. data/examples/agent_continue/add_error_handling/prompt.md +5 -0
  16. data/examples/agent_continue/analyze_codebase/prompt.md +7 -0
  17. data/examples/agent_continue/combined_workflow.yml +24 -0
  18. data/examples/agent_continue/continue_adding_features/prompt.md +4 -0
  19. data/examples/agent_continue/create_integration_tests/prompt.md +3 -0
  20. data/examples/agent_continue/document_with_context/prompt.md +5 -0
  21. data/examples/agent_continue/explore_api/prompt.md +6 -0
  22. data/examples/agent_continue/implement_client/prompt.md +6 -0
  23. data/examples/agent_continue/inline_workflow.yml +20 -0
  24. data/examples/agent_continue/refactor_code/prompt.md +2 -0
  25. data/examples/agent_continue/verify_changes/prompt.md +6 -0
  26. data/examples/agent_continue/workflow.yml +27 -0
  27. data/examples/agent_workflow/README.md +75 -0
  28. data/examples/agent_workflow/apply_refactorings/prompt.md +22 -0
  29. data/examples/agent_workflow/identify_code_smells/prompt.md +15 -0
  30. data/examples/agent_workflow/summarize_improvements/prompt.md +18 -0
  31. data/examples/agent_workflow/workflow.png +0 -0
  32. data/examples/agent_workflow/workflow.yml +16 -0
  33. data/examples/api_workflow/workflow.png +0 -0
  34. data/examples/apply_diff_demo/README.md +58 -0
  35. data/examples/apply_diff_demo/apply_simple_change/prompt.md +13 -0
  36. data/examples/apply_diff_demo/create_sample_file/prompt.md +11 -0
  37. data/examples/apply_diff_demo/workflow.yml +24 -0
  38. data/examples/available_tools_demo/README.md +42 -0
  39. data/examples/available_tools_demo/analyze_files/prompt.md +6 -0
  40. data/examples/available_tools_demo/explore_directory/prompt.md +6 -0
  41. data/examples/available_tools_demo/workflow.png +0 -0
  42. data/examples/available_tools_demo/workflow.yml +32 -0
  43. data/examples/available_tools_demo/write_summary/prompt.md +6 -0
  44. data/examples/bash_prototyping/api_testing.png +0 -0
  45. data/examples/bash_prototyping/system_analysis.png +0 -0
  46. data/examples/case_when/detect_language/prompt.md +2 -2
  47. data/examples/case_when/workflow.png +0 -0
  48. data/examples/cmd/basic_workflow.png +0 -0
  49. data/examples/cmd/dev_workflow.png +0 -0
  50. data/examples/cmd/explorer_workflow.png +0 -0
  51. data/examples/conditional/simple_workflow.png +0 -0
  52. data/examples/conditional/workflow.png +0 -0
  53. data/examples/context_management_demo/README.md +43 -0
  54. data/examples/context_management_demo/workflow.yml +42 -0
  55. data/examples/direct_coerce_syntax/workflow.png +0 -0
  56. data/examples/dot_notation/workflow.png +0 -0
  57. data/examples/exit_on_error/workflow.png +0 -0
  58. data/examples/grading/run_coverage.rb +0 -2
  59. data/examples/grading/workflow.png +0 -0
  60. data/examples/interpolation/workflow.png +0 -0
  61. data/examples/interpolation/workflow.yml +1 -1
  62. data/examples/iteration/analyze_complexity/prompt.md +2 -2
  63. data/examples/iteration/generate_recommendations/prompt.md +2 -2
  64. data/examples/iteration/implement_fix/prompt.md +2 -2
  65. data/examples/iteration/prioritize_issues/prompt.md +1 -1
  66. data/examples/iteration/prompts/analyze_file.md +2 -2
  67. data/examples/iteration/prompts/generate_summary.md +1 -1
  68. data/examples/iteration/prompts/update_report.md +3 -3
  69. data/examples/iteration/prompts/write_report.md +3 -3
  70. data/examples/iteration/read_file/prompt.md +2 -2
  71. data/examples/iteration/select_next_issue/prompt.md +2 -2
  72. data/examples/iteration/update_fix_count/prompt.md +4 -4
  73. data/examples/iteration/verify_fix/prompt.md +3 -3
  74. data/examples/iteration/workflow.png +0 -0
  75. data/examples/json_handling/workflow.png +0 -0
  76. data/examples/mcp/README.md +3 -3
  77. data/examples/mcp/analyze_changes/prompt.md +1 -1
  78. data/examples/mcp/database_workflow.png +0 -0
  79. data/examples/mcp/database_workflow.yml +1 -1
  80. data/examples/mcp/env_demo/workflow.png +0 -0
  81. data/examples/mcp/fetch_pr_context/prompt.md +1 -1
  82. data/examples/mcp/filesystem_demo/workflow.png +0 -0
  83. data/examples/mcp/github_workflow.png +0 -0
  84. data/examples/mcp/github_workflow.yml +1 -1
  85. data/examples/mcp/multi_mcp_workflow.png +0 -0
  86. data/examples/mcp/post_review/prompt.md +1 -1
  87. data/examples/mcp/workflow.png +0 -0
  88. data/examples/no_model_fallback/README.md +17 -0
  89. data/examples/no_model_fallback/analyze_file/prompt.md +1 -0
  90. data/examples/no_model_fallback/analyze_patterns/prompt.md +27 -0
  91. data/examples/no_model_fallback/generate_report_for_md/prompt.md +10 -0
  92. data/examples/no_model_fallback/generate_report_for_rb/prompt.md +3 -0
  93. data/examples/no_model_fallback/sample.rb +42 -0
  94. data/examples/no_model_fallback/workflow.yml +19 -0
  95. data/examples/openrouter_example/workflow.png +0 -0
  96. data/examples/pre_post_processing/analyze_test_file/prompt.md +1 -1
  97. data/examples/pre_post_processing/improve_test_coverage/prompt.md +1 -1
  98. data/examples/pre_post_processing/optimize_test_performance/prompt.md +1 -1
  99. data/examples/pre_post_processing/post_processing/aggregate_metrics/prompt.md +2 -2
  100. data/examples/pre_post_processing/post_processing/generate_summary_report/prompt.md +1 -1
  101. data/examples/pre_post_processing/pre_processing/setup_test_environment/prompt.md +1 -1
  102. data/examples/pre_post_processing/validate_changes/prompt.md +2 -2
  103. data/examples/pre_post_processing/workflow.png +0 -0
  104. data/examples/rspec_to_minitest/workflow.png +0 -0
  105. data/examples/shared_config/example_with_shared_config/workflow.png +0 -0
  106. data/examples/shared_config/shared.png +0 -0
  107. data/examples/single_target_prepost/workflow.png +0 -0
  108. data/examples/smart_coercion_defaults/workflow.png +0 -0
  109. data/examples/step_configuration/workflow.png +0 -0
  110. data/examples/swarm_example.yml +25 -0
  111. data/examples/tool_config_example/workflow.png +0 -0
  112. data/examples/user_input/README.md +90 -0
  113. data/examples/user_input/funny_name/create_backstory/prompt.md +10 -0
  114. data/examples/user_input/funny_name/workflow.png +0 -0
  115. data/examples/user_input/funny_name/workflow.yml +26 -0
  116. data/examples/user_input/generate_summary/prompt.md +11 -0
  117. data/examples/user_input/simple_input_demo/workflow.png +0 -0
  118. data/examples/user_input/simple_input_demo/workflow.yml +35 -0
  119. data/examples/user_input/survey_workflow.png +0 -0
  120. data/examples/user_input/survey_workflow.yml +71 -0
  121. data/examples/user_input/welcome_message/prompt.md +3 -0
  122. data/examples/user_input/workflow.png +0 -0
  123. data/examples/user_input/workflow.yml +73 -0
  124. data/examples/workflow_generator/create_workflow_files/prompt.md +1 -1
  125. data/examples/workflow_generator/workflow.png +0 -0
  126. data/lib/roast/errors.rb +6 -4
  127. data/lib/roast/helpers/function_caching_interceptor.rb +0 -2
  128. data/lib/roast/helpers/logger.rb +12 -35
  129. data/lib/roast/helpers/minitest_coverage_runner.rb +0 -1
  130. data/lib/roast/helpers/prompt_loader.rb +0 -2
  131. data/lib/roast/helpers/timeout_handler.rb +91 -0
  132. data/lib/roast/resources/api_resource.rb +0 -4
  133. data/lib/roast/resources/url_resource.rb +0 -3
  134. data/lib/roast/resources.rb +0 -8
  135. data/lib/roast/services/context_threshold_checker.rb +42 -0
  136. data/lib/roast/services/token_counting_service.rb +44 -0
  137. data/lib/roast/tools/apply_diff.rb +128 -0
  138. data/lib/roast/tools/ask_user.rb +0 -2
  139. data/lib/roast/tools/bash.rb +12 -9
  140. data/lib/roast/tools/cmd.rb +29 -12
  141. data/lib/roast/tools/coding_agent.rb +65 -17
  142. data/lib/roast/tools/context_summarizer.rb +108 -0
  143. data/lib/roast/tools/grep.rb +0 -3
  144. data/lib/roast/tools/helpers/coding_agent_message_formatter.rb +1 -4
  145. data/lib/roast/tools/read_file.rb +0 -2
  146. data/lib/roast/tools/search_file.rb +0 -2
  147. data/lib/roast/tools/swarm.rb +124 -0
  148. data/lib/roast/tools/update_files.rb +0 -4
  149. data/lib/roast/tools/write_file.rb +0 -3
  150. data/lib/roast/tools.rb +0 -13
  151. data/lib/roast/value_objects/step_name.rb +14 -3
  152. data/lib/roast/value_objects/workflow_path.rb +0 -2
  153. data/lib/roast/value_objects.rb +4 -4
  154. data/lib/roast/version.rb +1 -1
  155. data/lib/roast/workflow/agent_step.rb +33 -0
  156. data/lib/roast/workflow/api_configuration.rb +0 -4
  157. data/lib/roast/workflow/base_iteration_step.rb +3 -6
  158. data/lib/roast/workflow/base_step.rb +54 -28
  159. data/lib/roast/workflow/base_workflow.rb +43 -23
  160. data/lib/roast/workflow/case_executor.rb +0 -1
  161. data/lib/roast/workflow/case_step.rb +0 -4
  162. data/lib/roast/workflow/command_executor.rb +0 -2
  163. data/lib/roast/workflow/conditional_executor.rb +0 -1
  164. data/lib/roast/workflow/conditional_step.rb +0 -4
  165. data/lib/roast/workflow/configuration.rb +5 -67
  166. data/lib/roast/workflow/configuration_loader.rb +63 -3
  167. data/lib/roast/workflow/configuration_parser.rb +1 -7
  168. data/lib/roast/workflow/context_manager.rb +89 -0
  169. data/lib/roast/workflow/dot_access_hash.rb +16 -1
  170. data/lib/roast/workflow/each_step.rb +1 -1
  171. data/lib/roast/workflow/error_handler.rb +0 -3
  172. data/lib/roast/workflow/expression_evaluator.rb +0 -3
  173. data/lib/roast/workflow/file_state_repository.rb +0 -5
  174. data/lib/roast/workflow/input_executor.rb +41 -0
  175. data/lib/roast/workflow/input_step.rb +163 -0
  176. data/lib/roast/workflow/iteration_executor.rb +0 -2
  177. data/lib/roast/workflow/output_handler.rb +1 -3
  178. data/lib/roast/workflow/output_manager.rb +0 -2
  179. data/lib/roast/workflow/repeat_step.rb +1 -1
  180. data/lib/roast/workflow/replay_handler.rb +1 -4
  181. data/lib/roast/workflow/resource_resolver.rb +0 -3
  182. data/lib/roast/workflow/session_manager.rb +0 -3
  183. data/lib/roast/workflow/sqlite_state_repository.rb +342 -0
  184. data/lib/roast/workflow/state_manager.rb +2 -4
  185. data/lib/roast/workflow/state_repository_factory.rb +36 -0
  186. data/lib/roast/workflow/step_completion_reporter.rb +27 -0
  187. data/lib/roast/workflow/step_executor_coordinator.rb +48 -24
  188. data/lib/roast/workflow/step_executor_factory.rb +0 -5
  189. data/lib/roast/workflow/step_executor_registry.rb +1 -4
  190. data/lib/roast/workflow/step_executor_with_reporting.rb +68 -0
  191. data/lib/roast/workflow/step_executors/hash_step_executor.rb +0 -3
  192. data/lib/roast/workflow/step_executors/parallel_step_executor.rb +0 -3
  193. data/lib/roast/workflow/step_executors/string_step_executor.rb +0 -2
  194. data/lib/roast/workflow/step_factory.rb +56 -0
  195. data/lib/roast/workflow/step_loader.rb +31 -17
  196. data/lib/roast/workflow/step_name_extractor.rb +84 -0
  197. data/lib/roast/workflow/step_orchestrator.rb +3 -2
  198. data/lib/roast/workflow/step_type_resolver.rb +28 -1
  199. data/lib/roast/workflow/validation_command.rb +197 -0
  200. data/lib/roast/workflow/validator.rb +0 -4
  201. data/lib/roast/workflow/validators/base_validator.rb +44 -0
  202. data/lib/roast/workflow/validators/dependency_validator.rb +223 -0
  203. data/lib/roast/workflow/validators/linting_validator.rb +113 -0
  204. data/lib/roast/workflow/validators/schema_validator.rb +90 -0
  205. data/lib/roast/workflow/validators/step_collector.rb +57 -0
  206. data/lib/roast/workflow/validators/validation_orchestrator.rb +52 -0
  207. data/lib/roast/workflow/workflow_executor.rb +11 -20
  208. data/lib/roast/workflow/workflow_initializer.rb +1 -8
  209. data/lib/roast/workflow/workflow_runner.rb +6 -7
  210. data/lib/roast/workflow.rb +0 -15
  211. data/lib/roast/workflow_diagram_generator.rb +298 -0
  212. data/lib/roast.rb +212 -10
  213. data/roast.gemspec +4 -2
  214. data/schema/workflow.json +123 -1
  215. metadata +143 -6
  216. data/lib/roast/helpers.rb +0 -12
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "roast/workflow/command_executor"
4
- require "roast/workflow/expression_utils"
5
-
6
3
  module Roast
7
4
  module Workflow
8
5
  # Shared module for evaluating expressions in workflow steps
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "fileutils"
5
- require "roast/workflow/session_manager"
6
- require "roast/workflow/state_repository"
7
-
8
3
  module Roast
9
4
  module Workflow
10
5
  # File-based implementation of StateRepository
@@ -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 = FileStateRepository.new
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,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "roast/workflow/dot_access_hash"
4
-
5
3
  module Roast
6
4
  module Workflow
7
5
  # Manages workflow output, including both the key-value output hash
@@ -55,7 +55,7 @@ module Roast
55
55
  private
56
56
 
57
57
  def save_iteration_state(iteration)
58
- state_repository = FileStateRepository.new
58
+ state_repository = StateRepositoryFactory.create(workflow.storage_type)
59
59
 
60
60
  # Save the current iteration count in the state
61
61
  state_data = {
@@ -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 || FileStateRepository.new
12
+ @state_repository = state_repository || StateRepositoryFactory.create(workflow.storage_type)
16
13
  @processed = false
17
14
  end
18
15
 
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
4
- require "roast/resources"
5
-
6
3
  module Roast
7
4
  module Workflow
8
5
  # Handles resource resolution and target processing
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
- require "digest"
5
-
6
3
  module Roast
7
4
  module Workflow
8
5
  # Manages session creation, timestamping, and directory management
@@ -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 = FileStateRepository.new
12
+ @state_repository = state_repository || StateRepositoryFactory.create(storage_type)
15
13
  end
16
14
 
17
15
  # Save the current state after a step execution