roast-ai 0.4.0 → 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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +2 -2
  3. data/CHANGELOG.md +65 -0
  4. data/CLAUDE.md +55 -9
  5. data/Gemfile +1 -0
  6. data/Gemfile.lock +8 -1
  7. data/README.md +69 -3
  8. data/bin/console +1 -0
  9. data/docs/AGENT_STEPS.md +33 -9
  10. data/docs/VALIDATION.md +178 -0
  11. data/examples/agent_continue/add_documentation/prompt.md +5 -0
  12. data/examples/agent_continue/add_error_handling/prompt.md +5 -0
  13. data/examples/agent_continue/analyze_codebase/prompt.md +7 -0
  14. data/examples/agent_continue/combined_workflow.yml +24 -0
  15. data/examples/agent_continue/continue_adding_features/prompt.md +4 -0
  16. data/examples/agent_continue/create_integration_tests/prompt.md +3 -0
  17. data/examples/agent_continue/document_with_context/prompt.md +5 -0
  18. data/examples/agent_continue/explore_api/prompt.md +6 -0
  19. data/examples/agent_continue/implement_client/prompt.md +6 -0
  20. data/examples/agent_continue/inline_workflow.yml +20 -0
  21. data/examples/agent_continue/refactor_code/prompt.md +2 -0
  22. data/examples/agent_continue/verify_changes/prompt.md +6 -0
  23. data/examples/agent_continue/workflow.yml +27 -0
  24. data/examples/agent_workflow/workflow.png +0 -0
  25. data/examples/api_workflow/workflow.png +0 -0
  26. data/examples/apply_diff_demo/README.md +58 -0
  27. data/examples/apply_diff_demo/apply_simple_change/prompt.md +13 -0
  28. data/examples/apply_diff_demo/create_sample_file/prompt.md +11 -0
  29. data/examples/apply_diff_demo/workflow.yml +24 -0
  30. data/examples/available_tools_demo/workflow.png +0 -0
  31. data/examples/bash_prototyping/api_testing.png +0 -0
  32. data/examples/bash_prototyping/system_analysis.png +0 -0
  33. data/examples/case_when/workflow.png +0 -0
  34. data/examples/cmd/basic_workflow.png +0 -0
  35. data/examples/cmd/dev_workflow.png +0 -0
  36. data/examples/cmd/explorer_workflow.png +0 -0
  37. data/examples/conditional/simple_workflow.png +0 -0
  38. data/examples/conditional/workflow.png +0 -0
  39. data/examples/context_management_demo/README.md +43 -0
  40. data/examples/context_management_demo/workflow.yml +42 -0
  41. data/examples/direct_coerce_syntax/workflow.png +0 -0
  42. data/examples/dot_notation/workflow.png +0 -0
  43. data/examples/exit_on_error/workflow.png +0 -0
  44. data/examples/grading/workflow.png +0 -0
  45. data/examples/interpolation/workflow.png +0 -0
  46. data/examples/interpolation/workflow.yml +1 -1
  47. data/examples/iteration/workflow.png +0 -0
  48. data/examples/json_handling/workflow.png +0 -0
  49. data/examples/mcp/database_workflow.png +0 -0
  50. data/examples/mcp/env_demo/workflow.png +0 -0
  51. data/examples/mcp/filesystem_demo/workflow.png +0 -0
  52. data/examples/mcp/github_workflow.png +0 -0
  53. data/examples/mcp/multi_mcp_workflow.png +0 -0
  54. data/examples/mcp/workflow.png +0 -0
  55. data/examples/no_model_fallback/README.md +17 -0
  56. data/examples/no_model_fallback/analyze_file/prompt.md +1 -0
  57. data/examples/no_model_fallback/analyze_patterns/prompt.md +27 -0
  58. data/examples/no_model_fallback/generate_report_for_md/prompt.md +10 -0
  59. data/examples/no_model_fallback/generate_report_for_rb/prompt.md +3 -0
  60. data/examples/no_model_fallback/sample.rb +42 -0
  61. data/examples/no_model_fallback/workflow.yml +19 -0
  62. data/examples/openrouter_example/workflow.png +0 -0
  63. data/examples/pre_post_processing/workflow.png +0 -0
  64. data/examples/rspec_to_minitest/workflow.png +0 -0
  65. data/examples/shared_config/example_with_shared_config/workflow.png +0 -0
  66. data/examples/shared_config/shared.png +0 -0
  67. data/examples/single_target_prepost/workflow.png +0 -0
  68. data/examples/smart_coercion_defaults/workflow.png +0 -0
  69. data/examples/step_configuration/workflow.png +0 -0
  70. data/examples/swarm_example.yml +25 -0
  71. data/examples/tool_config_example/workflow.png +0 -0
  72. data/examples/user_input/funny_name/workflow.png +0 -0
  73. data/examples/user_input/simple_input_demo/workflow.png +0 -0
  74. data/examples/user_input/survey_workflow.png +0 -0
  75. data/examples/user_input/workflow.png +0 -0
  76. data/examples/workflow_generator/workflow.png +0 -0
  77. data/lib/roast/helpers/timeout_handler.rb +91 -0
  78. data/lib/roast/services/context_threshold_checker.rb +42 -0
  79. data/lib/roast/services/token_counting_service.rb +44 -0
  80. data/lib/roast/tools/apply_diff.rb +128 -0
  81. data/lib/roast/tools/bash.rb +15 -9
  82. data/lib/roast/tools/cmd.rb +32 -12
  83. data/lib/roast/tools/coding_agent.rb +64 -9
  84. data/lib/roast/tools/context_summarizer.rb +108 -0
  85. data/lib/roast/tools/swarm.rb +124 -0
  86. data/lib/roast/version.rb +1 -1
  87. data/lib/roast/workflow/agent_step.rb +9 -2
  88. data/lib/roast/workflow/base_iteration_step.rb +3 -2
  89. data/lib/roast/workflow/base_workflow.rb +41 -2
  90. data/lib/roast/workflow/configuration.rb +2 -1
  91. data/lib/roast/workflow/configuration_loader.rb +63 -1
  92. data/lib/roast/workflow/context_manager.rb +89 -0
  93. data/lib/roast/workflow/each_step.rb +1 -1
  94. data/lib/roast/workflow/output_handler.rb +1 -1
  95. data/lib/roast/workflow/repeat_step.rb +1 -1
  96. data/lib/roast/workflow/replay_handler.rb +1 -1
  97. data/lib/roast/workflow/sqlite_state_repository.rb +342 -0
  98. data/lib/roast/workflow/state_manager.rb +2 -2
  99. data/lib/roast/workflow/state_repository_factory.rb +36 -0
  100. data/lib/roast/workflow/step_completion_reporter.rb +27 -0
  101. data/lib/roast/workflow/step_executor_coordinator.rb +19 -18
  102. data/lib/roast/workflow/step_executor_with_reporting.rb +68 -0
  103. data/lib/roast/workflow/step_loader.rb +1 -1
  104. data/lib/roast/workflow/step_name_extractor.rb +84 -0
  105. data/lib/roast/workflow/validation_command.rb +197 -0
  106. data/lib/roast/workflow/validators/base_validator.rb +44 -0
  107. data/lib/roast/workflow/validators/dependency_validator.rb +223 -0
  108. data/lib/roast/workflow/validators/linting_validator.rb +113 -0
  109. data/lib/roast/workflow/validators/schema_validator.rb +90 -0
  110. data/lib/roast/workflow/validators/step_collector.rb +57 -0
  111. data/lib/roast/workflow/validators/validation_orchestrator.rb +52 -0
  112. data/lib/roast/workflow/workflow_executor.rb +11 -4
  113. data/lib/roast/workflow/workflow_runner.rb +6 -0
  114. data/lib/roast/workflow_diagram_generator.rb +298 -0
  115. data/lib/roast.rb +157 -0
  116. data/roast.gemspec +2 -1
  117. data/schema/workflow.json +77 -1
  118. metadata +101 -1
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ module Validators
6
+ # Collects and caches all steps from a workflow configuration
7
+ class StepCollector
8
+ def initialize(parsed_yaml)
9
+ @parsed_yaml = parsed_yaml
10
+ @all_steps = nil
11
+ end
12
+
13
+ def all_steps
14
+ @all_steps ||= collect_all_steps(@parsed_yaml)
15
+ end
16
+
17
+ private
18
+
19
+ def collect_all_steps(config, steps = [])
20
+ # Recursively collect all steps from the configuration
21
+ ["steps", "pre_processing", "post_processing"].each do |key|
22
+ if config[key]
23
+ steps.concat(extract_steps_from_array(config[key]))
24
+ end
25
+ end
26
+ steps
27
+ end
28
+
29
+ def extract_steps_from_array(steps_array, collected = [])
30
+ steps_array.each do |step|
31
+ case step
32
+ when String
33
+ collected << step
34
+ when Hash
35
+ if step["steps"]
36
+ collected.concat(extract_steps_from_array(step["steps"]))
37
+ end
38
+ # Handle conditional steps
39
+ ["then", "else", "true", "false"].each do |branch|
40
+ if step[branch]
41
+ collected.concat(extract_steps_from_array(step[branch]))
42
+ end
43
+ end
44
+ # Handle case/when steps
45
+ step["when"]&.each_value do |when_steps|
46
+ collected.concat(extract_steps_from_array(when_steps))
47
+ end
48
+ when Array
49
+ collected.concat(extract_steps_from_array(step))
50
+ end
51
+ end
52
+ collected
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ module Validators
6
+ # Orchestrates all validators and aggregates results
7
+ class ValidationOrchestrator
8
+ attr_reader :errors, :warnings
9
+
10
+ def initialize(yaml_content, workflow_path = nil)
11
+ @yaml_content = yaml_content
12
+ @workflow_path = workflow_path
13
+ @errors = []
14
+ @warnings = []
15
+ end
16
+
17
+ def valid?
18
+ # First run schema validation
19
+ schema_validator = SchemaValidator.new(@yaml_content, @workflow_path)
20
+
21
+ unless schema_validator.valid?
22
+ @errors = schema_validator.errors
23
+ @warnings = schema_validator.warnings
24
+ return false
25
+ end
26
+
27
+ parsed_yaml = schema_validator.parsed_yaml
28
+
29
+ # If schema is valid, run other validators
30
+ if @errors.empty?
31
+ step_collector = StepCollector.new(parsed_yaml)
32
+
33
+ # Run dependency validation
34
+ dependency_validator = DependencyValidator.new(parsed_yaml, @workflow_path, step_collector: step_collector)
35
+ dependency_validator.validate
36
+ @errors.concat(dependency_validator.errors)
37
+ @warnings.concat(dependency_validator.warnings)
38
+
39
+ # Run linting only if no errors
40
+ if @errors.empty?
41
+ linting_validator = LintingValidator.new(parsed_yaml, @workflow_path, step_collector: step_collector)
42
+ linting_validator.validate
43
+ @warnings.concat(linting_validator.warnings)
44
+ end
45
+ end
46
+
47
+ @errors.empty?
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -66,13 +66,13 @@ module Roast
66
66
  @step_loader = step_loader || StepLoader.new(workflow, config_hash, context_path, phase: phase)
67
67
  @command_executor = command_executor || CommandExecutor.new(logger: @error_handler)
68
68
  @interpolator = interpolator || Interpolator.new(workflow, logger: @error_handler)
69
- @state_manager = state_manager || StateManager.new(workflow, logger: @error_handler)
69
+ @state_manager = state_manager || StateManager.new(workflow, logger: @error_handler, storage_type: workflow.storage_type)
70
70
  @iteration_executor = iteration_executor || IterationExecutor.new(workflow, context_path, @state_manager, config_hash)
71
71
  @conditional_executor = conditional_executor || ConditionalExecutor.new(workflow, context_path, @state_manager, self)
72
72
  @step_orchestrator = step_orchestrator || StepOrchestrator.new(workflow, @step_loader, @state_manager, @error_handler, self)
73
73
 
74
74
  # Initialize coordinator with dependencies
75
- @step_executor_coordinator = step_executor_coordinator || StepExecutorCoordinator.new(
75
+ base_coordinator = step_executor_coordinator || StepExecutorCoordinator.new(
76
76
  context: @context,
77
77
  dependencies: {
78
78
  workflow_executor: self,
@@ -84,6 +84,13 @@ module Roast
84
84
  error_handler: @error_handler,
85
85
  },
86
86
  )
87
+
88
+ # Only wrap with reporting decorator if workflow has token tracking enabled
89
+ @step_executor_coordinator = if workflow.respond_to?(:context_manager) && workflow.context_manager
90
+ StepExecutorWithReporting.new(base_coordinator, @context)
91
+ else
92
+ base_coordinator
93
+ end
87
94
  end
88
95
 
89
96
  # Logger interface methods for backward compatibility
@@ -111,8 +118,8 @@ module Roast
111
118
  @interpolator.interpolate(text)
112
119
  end
113
120
 
114
- def execute_step(name, exit_on_error: true)
115
- @step_executor_coordinator.execute(name, exit_on_error: exit_on_error)
121
+ def execute_step(name, exit_on_error: true, is_last_step: nil)
122
+ @step_executor_coordinator.execute(name, exit_on_error:, is_last_step:)
116
123
  rescue StepLoader::StepNotFoundError => e
117
124
  raise StepNotFoundError.new(e.message, step_name: e.step_name, original_error: e.original_error)
118
125
  rescue StepLoader::StepExecutionError => e
@@ -167,6 +167,12 @@ module Roast
167
167
  workflow.verbose = @options[:verbose] if @options[:verbose].present?
168
168
  workflow.concise = @options[:concise] if @options[:concise].present?
169
169
  workflow.pause_step_name = @options[:pause] if @options[:pause].present?
170
+ # Set storage type based on CLI option (default is SQLite unless --file-storage is used)
171
+ workflow.storage_type = @options[:file_storage] ? "file" : nil
172
+ # Set model from configuration with fallback to default
173
+ workflow.model = @configuration.model || StepLoader::DEFAULT_MODEL
174
+ # Set context management configuration
175
+ workflow.context_management_config = @configuration.context_management
170
176
  end
171
177
  end
172
178
 
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ class WorkflowDiagramGenerator
5
+ def initialize(workflow_config, workflow_file_path = nil)
6
+ @workflow_config = workflow_config
7
+ @workflow_file_path = workflow_file_path
8
+ @graph = GraphViz.new(:G, type: :digraph)
9
+ @node_counter = 0
10
+ @nodes = {}
11
+ end
12
+
13
+ def generate(custom_output_path = nil)
14
+ configure_graph
15
+ build_graph(@workflow_config.steps)
16
+
17
+ output_path = custom_output_path || generate_output_filename
18
+ @graph.output(png: output_path)
19
+ output_path
20
+ end
21
+
22
+ private
23
+
24
+ def configure_graph
25
+ @graph[:rankdir] = "TB"
26
+ @graph[:fontname] = "Helvetica"
27
+ @graph[:fontsize] = "12"
28
+ @graph[:bgcolor] = "white"
29
+ @graph[:pad] = "0.5"
30
+ @graph[:nodesep] = "0.7"
31
+ @graph[:ranksep] = "0.8"
32
+ @graph[:splines] = "spline"
33
+
34
+ # Default node styling
35
+ @graph.node[:shape] = "box"
36
+ @graph.node[:style] = "rounded,filled"
37
+ @graph.node[:fillcolor] = "#E8F4FD"
38
+ @graph.node[:color] = "#2563EB"
39
+ @graph.node[:fontname] = "Helvetica"
40
+ @graph.node[:fontsize] = "11"
41
+ @graph.node[:fontcolor] = "#1E293B"
42
+ @graph.node[:penwidth] = "1.5"
43
+ @graph.node[:height] = "0.6"
44
+ @graph.node[:margin] = "0.15"
45
+
46
+ # Edge styling
47
+ @graph.edge[:fontname] = "Helvetica"
48
+ @graph.edge[:fontsize] = "10"
49
+ @graph.edge[:color] = "#64748B"
50
+ @graph.edge[:penwidth] = "1.5"
51
+ @graph.edge[:arrowsize] = "0.8"
52
+ end
53
+
54
+ def build_graph(steps, parent_node = nil)
55
+ previous_node = parent_node
56
+
57
+ steps.each do |step|
58
+ current_node = process_step(step)
59
+
60
+ if previous_node && current_node
61
+ @graph.add_edges(previous_node, current_node)
62
+ end
63
+
64
+ previous_node = current_node unless current_node.nil?
65
+ end
66
+
67
+ previous_node
68
+ end
69
+
70
+ def process_step(step)
71
+ case step
72
+ when String
73
+ create_step_node(step)
74
+ when Hash
75
+ process_control_flow(step)
76
+ else
77
+ ::CLI::Kit.logger.warn("Unexpected step type in workflow diagram: #{step.class} - #{step.inspect}")
78
+ nil
79
+ end
80
+ end
81
+
82
+ def create_step_node(step_name)
83
+ node_id = next_node_id
84
+ label = step_name
85
+
86
+ # Check if it's an inline prompt
87
+ @nodes[node_id] = if step_name.start_with?("prompt:")
88
+ @graph.add_nodes(
89
+ node_id,
90
+ label: truncate_label(step_name[7..].strip),
91
+ fillcolor: "#FEF3C7",
92
+ color: "#F59E0B",
93
+ shape: "note",
94
+ fontsize: "10",
95
+ )
96
+ else
97
+ @graph.add_nodes(node_id, label: label)
98
+ end
99
+
100
+ @nodes[node_id]
101
+ end
102
+
103
+ def process_control_flow(control_flow)
104
+ if control_flow.key?("if") || control_flow.key?("unless")
105
+ process_conditional(control_flow)
106
+ elsif control_flow.key?("each") || control_flow.key?("repeat")
107
+ process_loop(control_flow)
108
+ elsif control_flow.key?("input")
109
+ process_input(control_flow)
110
+ elsif control_flow.key?("proceed?")
111
+ process_proceed(control_flow)
112
+ elsif control_flow.key?("case")
113
+ process_case(control_flow)
114
+ else
115
+ ::CLI::Kit.logger.warn("Unexpected control flow structure in workflow diagram: #{control_flow.keys.join(", ")}")
116
+ nil
117
+ end
118
+ end
119
+
120
+ def process_conditional(conditional)
121
+ condition_type = conditional.key?("if") ? "if" : "unless"
122
+ condition = conditional[condition_type]
123
+
124
+ # Create diamond decision node
125
+ decision_id = next_node_id
126
+ decision_node = @graph.add_nodes(
127
+ decision_id,
128
+ label: "#{condition_type}: #{condition}",
129
+ shape: "diamond",
130
+ fillcolor: "#FEE2E2",
131
+ color: "#DC2626",
132
+ fontsize: "10",
133
+ height: "0.8",
134
+ width: "1.2",
135
+ )
136
+
137
+ # Process then branch
138
+ if conditional["then"]
139
+ then_steps = Array(conditional["then"])
140
+ if then_steps.any?
141
+ build_graph(then_steps, decision_node)
142
+ end
143
+ end
144
+
145
+ # Process else branch
146
+ if conditional["else"]
147
+ else_steps = Array(conditional["else"])
148
+ if else_steps.any?
149
+ build_graph(else_steps, decision_node)
150
+ end
151
+ end
152
+
153
+ decision_node
154
+ end
155
+
156
+ def process_loop(loop_control)
157
+ loop_type = loop_control.key?("each") ? "each" : "repeat"
158
+ loop_value = loop_control[loop_type]
159
+
160
+ # Create loop node
161
+ loop_id = next_node_id
162
+ loop_label = loop_type == "each" ? "each: #{loop_value}" : "repeat: #{loop_value}"
163
+ loop_node = @graph.add_nodes(
164
+ loop_id,
165
+ label: loop_label,
166
+ shape: "box3d",
167
+ fillcolor: "#D1FAE5",
168
+ color: "#10B981",
169
+ fontsize: "10",
170
+ penwidth: "2",
171
+ )
172
+
173
+ # Process loop body
174
+ if loop_control["do"]
175
+ loop_steps = Array(loop_control["do"])
176
+ if loop_steps.any?
177
+ last_loop_node = build_graph(loop_steps, loop_node)
178
+ # Add back edge to show loop
179
+ @graph.add_edges(
180
+ last_loop_node,
181
+ loop_node,
182
+ style: "dashed",
183
+ label: "loop",
184
+ color: "#10B981",
185
+ fontcolor: "#10B981",
186
+ arrowhead: "empty",
187
+ )
188
+ end
189
+ end
190
+
191
+ loop_node
192
+ end
193
+
194
+ def process_input(input_control)
195
+ input_id = next_node_id
196
+ label = input_control["input"]
197
+ input_node = @graph.add_nodes(
198
+ input_id,
199
+ label: "input: #{label}",
200
+ shape: "parallelogram",
201
+ fillcolor: "#F3F4F6",
202
+ color: "#6B7280",
203
+ fontsize: "10",
204
+ )
205
+ input_node
206
+ end
207
+
208
+ def process_proceed(proceed_control)
209
+ proceed_id = next_node_id
210
+ proceed_node = @graph.add_nodes(
211
+ proceed_id,
212
+ label: "proceed?",
213
+ shape: "diamond",
214
+ fillcolor: "#FED7AA",
215
+ color: "#EA580C",
216
+ fontsize: "10",
217
+ height: "0.8",
218
+ )
219
+
220
+ # Process do branch if present
221
+ if proceed_control["do"]
222
+ proceed_steps = Array(proceed_control["do"])
223
+ if proceed_steps.any?
224
+ build_graph(proceed_steps, proceed_node)
225
+ end
226
+ end
227
+
228
+ proceed_node
229
+ end
230
+
231
+ def process_case(case_control)
232
+ case_id = next_node_id
233
+ case_node = @graph.add_nodes(
234
+ case_id,
235
+ label: "case: #{case_control["case"]}",
236
+ shape: "diamond",
237
+ fillcolor: "#E9D5FF",
238
+ color: "#9333EA",
239
+ fontsize: "10",
240
+ height: "0.8",
241
+ width: "1.5",
242
+ )
243
+
244
+ # Process when branches
245
+ case_control["when"].each do |condition, steps|
246
+ when_steps = Array(steps)
247
+ next if when_steps.none?
248
+
249
+ first_when_node = process_step(when_steps.first)
250
+ @graph.add_edges(
251
+ case_node,
252
+ first_when_node,
253
+ label: condition.to_s,
254
+ fontcolor: "#9333EA",
255
+ )
256
+
257
+ if when_steps.length > 1
258
+ build_graph(when_steps[1..], first_when_node)
259
+ end
260
+ end
261
+
262
+ case_node
263
+ end
264
+
265
+ def next_node_id
266
+ @node_counter += 1
267
+ "node_#{@node_counter}"
268
+ end
269
+
270
+ def truncate_label(text, max_length = 50)
271
+ return text if text.length <= max_length
272
+
273
+ "#{text[0...max_length]}..."
274
+ end
275
+
276
+ def generate_output_filename
277
+ if @workflow_file_path
278
+ # Get the directory and base name of the workflow file
279
+ dir = File.dirname(@workflow_file_path)
280
+ base = File.basename(@workflow_file_path, ".yml")
281
+
282
+ # Create the diagram filename in the same directory
283
+ File.join(dir, "#{base}.png")
284
+ else
285
+ # Fallback to workflow name if no file path provided
286
+ workflow_name = @workflow_config.name
287
+ sanitized_name = workflow_name
288
+ .downcase
289
+ .gsub(/[^a-z0-9]+/, "_")
290
+ .gsub(/^_|_$/, "")
291
+ .gsub(/_+/, "_")
292
+
293
+ sanitized_name = "workflow" if sanitized_name.empty?
294
+ "#{sanitized_name}_diagram.png"
295
+ end
296
+ end
297
+ end
298
+ end
data/lib/roast.rb CHANGED
@@ -30,6 +30,7 @@ require "json-schema"
30
30
  require "raix"
31
31
  require "raix/chat_completion"
32
32
  require "raix/function_dispatch"
33
+ require "ruby-graphviz"
33
34
  require "thor"
34
35
 
35
36
  # Autoloading setup
@@ -50,6 +51,7 @@ module Roast
50
51
  option :target, type: :string, aliases: "-t", desc: "Override target files. Can be file path, glob pattern, or $(shell command)"
51
52
  option :replay, type: :string, aliases: "-r", desc: "Resume workflow from a specific step. Format: step_name or session_timestamp:step_name"
52
53
  option :pause, type: :string, aliases: "-p", desc: "Pause workflow after a specific step. Format: step_name"
54
+ option :file_storage, type: :boolean, aliases: "-f", desc: "Use filesystem storage for sessions instead of SQLite"
53
55
 
54
56
  def execute(*paths)
55
57
  raise Thor::Error, "Workflow configuration file is required" if paths.empty?
@@ -67,6 +69,44 @@ module Roast
67
69
  Roast::Workflow::ConfigurationParser.new(expanded_workflow_path, files, options.transform_keys(&:to_sym)).begin!
68
70
  end
69
71
 
72
+ desc "resume WORKFLOW_FILE", "Resume a paused workflow with an event"
73
+ option :event, type: :string, aliases: "-e", required: true, desc: "Event name to trigger"
74
+ option :session_id, type: :string, aliases: "-s", desc: "Specific session ID to resume (defaults to most recent)"
75
+ option :event_data, type: :string, desc: "JSON data to pass with the event"
76
+ def resume(workflow_path)
77
+ expanded_workflow_path = if workflow_path.include?("workflow.yml")
78
+ File.expand_path(workflow_path)
79
+ else
80
+ File.expand_path("roast/#{workflow_path}/workflow.yml")
81
+ end
82
+
83
+ unless File.exist?(expanded_workflow_path)
84
+ raise Thor::Error, "Workflow file not found: #{expanded_workflow_path}"
85
+ end
86
+
87
+ # Store the event in the session
88
+ repository = Workflow::StateRepositoryFactory.create
89
+
90
+ unless repository.respond_to?(:add_event)
91
+ raise Thor::Error, "Event resumption requires SQLite storage. Set ROAST_STATE_STORAGE=sqlite"
92
+ end
93
+
94
+ # Parse event data if provided
95
+ event_data = options[:event_data] ? JSON.parse(options[:event_data]) : nil
96
+
97
+ # Add the event to the session
98
+ session_id = options[:session_id]
99
+ repository.add_event(expanded_workflow_path, session_id, options[:event], event_data)
100
+
101
+ # Resume workflow execution from the wait state
102
+ resume_options = options.transform_keys(&:to_sym).merge(
103
+ resume_from_event: options[:event],
104
+ session_id: session_id,
105
+ )
106
+
107
+ Roast::Workflow::ConfigurationParser.new(expanded_workflow_path, [], resume_options).begin!
108
+ end
109
+
70
110
  desc "version", "Display the current version of Roast"
71
111
  def version
72
112
  puts "Roast version #{Roast::VERSION}"
@@ -108,6 +148,123 @@ module Roast
108
148
  puts "Run a workflow with: roast execute <workflow_name>"
109
149
  end
110
150
 
151
+ desc "validate [WORKFLOW_CONFIGURATION_FILE]", "Validate a workflow configuration"
152
+ option :strict, type: :boolean, aliases: "-s", desc: "Treat warnings as errors"
153
+ def validate(workflow_path = nil)
154
+ validation_command = Roast::Workflow::ValidationCommand.new(options)
155
+ validation_command.execute(workflow_path)
156
+ end
157
+
158
+ desc "sessions", "List stored workflow sessions"
159
+ option :status, type: :string, aliases: "-s", desc: "Filter by status (running, waiting, completed, failed)"
160
+ option :workflow, type: :string, aliases: "-w", desc: "Filter by workflow name"
161
+ option :older_than, type: :string, desc: "Show sessions older than specified time (e.g., '7d', '1h')"
162
+ option :cleanup, type: :boolean, desc: "Clean up old sessions"
163
+ def sessions
164
+ repository = Workflow::StateRepositoryFactory.create
165
+
166
+ unless repository.respond_to?(:list_sessions)
167
+ raise Thor::Error, "Session listing is only available with SQLite storage. Set ROAST_STATE_STORAGE=sqlite"
168
+ end
169
+
170
+ if options[:cleanup] && options[:older_than]
171
+ count = repository.cleanup_old_sessions(options[:older_than])
172
+ puts "Cleaned up #{count} old sessions"
173
+ return
174
+ end
175
+
176
+ sessions = repository.list_sessions(
177
+ status: options[:status],
178
+ workflow_name: options[:workflow],
179
+ older_than: options[:older_than],
180
+ )
181
+
182
+ if sessions.empty?
183
+ puts "No sessions found"
184
+ return
185
+ end
186
+
187
+ puts "Found #{sessions.length} session(s):"
188
+ puts
189
+
190
+ sessions.each do |session|
191
+ id, workflow_name, _, status, current_step, created_at, updated_at = session
192
+
193
+ puts "Session: #{id}"
194
+ puts " Workflow: #{workflow_name}"
195
+ puts " Status: #{status}"
196
+ puts " Current step: #{current_step || "N/A"}"
197
+ puts " Created: #{created_at}"
198
+ puts " Updated: #{updated_at}"
199
+ puts
200
+ end
201
+ end
202
+
203
+ desc "session SESSION_ID", "Show details for a specific session"
204
+ def session(session_id)
205
+ repository = Workflow::StateRepositoryFactory.create
206
+
207
+ unless repository.respond_to?(:get_session_details)
208
+ raise Thor::Error, "Session details are only available with SQLite storage. Set ROAST_STATE_STORAGE=sqlite"
209
+ end
210
+
211
+ details = repository.get_session_details(session_id)
212
+
213
+ unless details
214
+ raise Thor::Error, "Session not found: #{session_id}"
215
+ end
216
+
217
+ session = details[:session]
218
+ states = details[:states]
219
+ events = details[:events]
220
+
221
+ puts "Session: #{session[0]}"
222
+ puts "Workflow: #{session[1]}"
223
+ puts "Path: #{session[2]}"
224
+ puts "Status: #{session[3]}"
225
+ puts "Created: #{session[6]}"
226
+ puts "Updated: #{session[7]}"
227
+
228
+ if session[5]
229
+ puts
230
+ puts "Final output:"
231
+ puts session[5]
232
+ end
233
+
234
+ if states && !states.empty?
235
+ puts
236
+ puts "Steps executed:"
237
+ states.each do |step_index, step_name, created_at|
238
+ puts " #{step_index}: #{step_name} (#{created_at})"
239
+ end
240
+ end
241
+
242
+ if events && !events.empty?
243
+ puts
244
+ puts "Events:"
245
+ events.each do |event_name, event_data, received_at|
246
+ puts " #{event_name} at #{received_at}"
247
+ puts " Data: #{event_data}" if event_data
248
+ end
249
+ end
250
+ end
251
+
252
+ desc "diagram WORKFLOW_FILE", "Generate a visual diagram of a workflow"
253
+ option :output, type: :string, aliases: "-o", desc: "Output file path (defaults to workflow_name_diagram.png)"
254
+ def diagram(workflow_file)
255
+ unless File.exist?(workflow_file)
256
+ raise Thor::Error, "Workflow file not found: #{workflow_file}"
257
+ end
258
+
259
+ workflow = Workflow::Configuration.new(workflow_file)
260
+ generator = WorkflowDiagramGenerator.new(workflow, workflow_file)
261
+ output_path = generator.generate(options[:output])
262
+
263
+ puts ::CLI::UI.fmt("{{success:✓}} Diagram generated: #{output_path}")
264
+ rescue StandardError => e
265
+ raise Thor::Error, "Error generating diagram: #{e.message}"
266
+ end
267
+
111
268
  private
112
269
 
113
270
  def show_example_picker
data/roast.gemspec CHANGED
@@ -30,7 +30,7 @@ Gem::Specification.new do |spec|
30
30
  # Specify which files should be added to the gem when it is released.
31
31
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
32
32
  spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
33
- %x(git ls-files -z).split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
33
+ %x(git ls-files -z).split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) || f.end_with?(".gem") }
34
34
  end
35
35
  spec.bindir = "exe"
36
36
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
@@ -43,6 +43,7 @@ Gem::Specification.new do |spec|
43
43
  spec.add_dependency("json-schema")
44
44
  spec.add_dependency("open_router", "~> 0.3")
45
45
  spec.add_dependency("raix", "~> 1.0")
46
+ spec.add_dependency("ruby-graphviz", "~> 1.2")
46
47
  spec.add_dependency("thor", "~> 1.3")
47
48
  spec.add_dependency("zeitwerk", "~> 2.6")
48
49
  end