roast-ai 0.4.0 → 0.4.2
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 +103 -0
- data/CLAUDE.md +55 -9
- data/Gemfile.lock +19 -10
- data/README.md +69 -3
- data/bin/console +1 -0
- data/docs/AGENT_STEPS.md +33 -9
- 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/workflow.png +0 -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/workflow.png +0 -0
- data/examples/bash_prototyping/api_testing.png +0 -0
- data/examples/bash_prototyping/system_analysis.png +0 -0
- 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/rb_test_runner +1 -1
- data/examples/grading/workflow.png +0 -0
- data/examples/interpolation/workflow.png +0 -0
- data/examples/interpolation/workflow.yml +1 -1
- data/examples/iteration/workflow.png +0 -0
- data/examples/json_handling/workflow.png +0 -0
- data/examples/mcp/database_workflow.png +0 -0
- data/examples/mcp/env_demo/workflow.png +0 -0
- data/examples/mcp/filesystem_demo/workflow.png +0 -0
- data/examples/mcp/github_workflow.png +0 -0
- data/examples/mcp/multi_mcp_workflow.png +0 -0
- 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/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/funny_name/workflow.png +0 -0
- data/examples/user_input/simple_input_demo/workflow.png +0 -0
- data/examples/user_input/survey_workflow.png +0 -0
- data/examples/user_input/workflow.png +0 -0
- data/examples/workflow_generator/workflow.png +0 -0
- data/lib/roast/errors.rb +3 -0
- data/lib/roast/helpers/timeout_handler.rb +91 -0
- 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/bash.rb +15 -9
- data/lib/roast/tools/cmd.rb +32 -12
- data/lib/roast/tools/coding_agent.rb +65 -10
- data/lib/roast/tools/context_summarizer.rb +108 -0
- data/lib/roast/tools/swarm.rb +124 -0
- data/lib/roast/version.rb +1 -1
- data/lib/roast/workflow/agent_step.rb +9 -2
- data/lib/roast/workflow/base_iteration_step.rb +3 -2
- data/lib/roast/workflow/base_workflow.rb +41 -2
- data/lib/roast/workflow/command_executor.rb +3 -1
- data/lib/roast/workflow/configuration.rb +2 -1
- data/lib/roast/workflow/configuration_loader.rb +63 -1
- data/lib/roast/workflow/configuration_parser.rb +2 -0
- data/lib/roast/workflow/context_manager.rb +89 -0
- data/lib/roast/workflow/each_step.rb +1 -1
- data/lib/roast/workflow/input_step.rb +2 -0
- data/lib/roast/workflow/interpolator.rb +23 -1
- data/lib/roast/workflow/output_handler.rb +1 -1
- data/lib/roast/workflow/repeat_step.rb +1 -1
- data/lib/roast/workflow/replay_handler.rb +1 -1
- data/lib/roast/workflow/sqlite_state_repository.rb +342 -0
- data/lib/roast/workflow/state_manager.rb +2 -2
- 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 +19 -18
- data/lib/roast/workflow/step_executor_with_reporting.rb +68 -0
- data/lib/roast/workflow/step_loader.rb +1 -1
- data/lib/roast/workflow/step_name_extractor.rb +84 -0
- data/lib/roast/workflow/validation_command.rb +197 -0
- 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 -4
- data/lib/roast/workflow/workflow_initializer.rb +80 -0
- data/lib/roast/workflow/workflow_runner.rb +6 -0
- data/lib/roast/workflow_diagram_generator.rb +298 -0
- data/lib/roast.rb +158 -0
- data/roast.gemspec +4 -1
- data/schema/workflow.json +77 -1
- metadata +129 -1
@@ -0,0 +1,223 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
module Validators
|
6
|
+
# Validates dependencies: tools, step references, and resources
|
7
|
+
class DependencyValidator < BaseValidator
|
8
|
+
def initialize(parsed_yaml, workflow_path = nil, step_collector: nil)
|
9
|
+
super(parsed_yaml, workflow_path)
|
10
|
+
@step_collector = step_collector || StepCollector.new(parsed_yaml)
|
11
|
+
end
|
12
|
+
|
13
|
+
def validate
|
14
|
+
validate_tool_dependencies
|
15
|
+
validate_step_references
|
16
|
+
validate_resource_dependencies
|
17
|
+
end
|
18
|
+
|
19
|
+
private
|
20
|
+
|
21
|
+
def validate_tool_dependencies
|
22
|
+
return unless @parsed_yaml["tools"]
|
23
|
+
|
24
|
+
tools = extract_all_tools(@parsed_yaml["tools"])
|
25
|
+
|
26
|
+
tools.each do |tool|
|
27
|
+
next if tool_available?(tool)
|
28
|
+
|
29
|
+
add_error(
|
30
|
+
type: :tool_dependency,
|
31
|
+
tool: tool,
|
32
|
+
message: "Tool '#{tool}' is not available",
|
33
|
+
suggestion: suggest_tool_fix(tool),
|
34
|
+
)
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def validate_step_references
|
39
|
+
all_steps = @step_collector.all_steps
|
40
|
+
step_names = all_steps.map { |s| extract_step_name(s) }.compact.uniq
|
41
|
+
|
42
|
+
check_step_references_in_config(@parsed_yaml, step_names)
|
43
|
+
end
|
44
|
+
|
45
|
+
def validate_resource_dependencies
|
46
|
+
# Validate file resources if target is specified
|
47
|
+
if @parsed_yaml["target"] && @workflow_path
|
48
|
+
validate_target_resource(@parsed_yaml["target"])
|
49
|
+
end
|
50
|
+
|
51
|
+
# Validate prompt files exist
|
52
|
+
validate_prompt_files if @workflow_path
|
53
|
+
end
|
54
|
+
|
55
|
+
def tool_available?(tool_name)
|
56
|
+
# Check if it's an MCP tool first
|
57
|
+
tools_config = @parsed_yaml["tools"] || []
|
58
|
+
tools_config.each do |tool_entry|
|
59
|
+
if tool_entry.is_a?(Hash) && tool_entry.keys.include?(tool_name)
|
60
|
+
# It's an MCP tool configuration
|
61
|
+
return true
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Check if tool module exists
|
66
|
+
begin
|
67
|
+
tool_name.constantize
|
68
|
+
true
|
69
|
+
rescue NameError
|
70
|
+
false
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
def suggest_tool_fix(tool)
|
75
|
+
# Suggest similar tools or common fixes
|
76
|
+
available_tools = [
|
77
|
+
"Roast::Tools::Bash",
|
78
|
+
"Roast::Tools::Cmd",
|
79
|
+
"Roast::Tools::ReadFile",
|
80
|
+
"Roast::Tools::WriteFile",
|
81
|
+
"Roast::Tools::UpdateFiles",
|
82
|
+
"Roast::Tools::SearchFile",
|
83
|
+
"Roast::Tools::Grep",
|
84
|
+
"Roast::Tools::AskUser",
|
85
|
+
"Roast::Tools::CodingAgent",
|
86
|
+
]
|
87
|
+
|
88
|
+
# Simple similarity check
|
89
|
+
tool_base = tool.split("::").last&.downcase || tool.downcase
|
90
|
+
suggestions = available_tools.select do |t|
|
91
|
+
t_base = t.split("::").last&.downcase || ""
|
92
|
+
t_base.include?(tool_base) || tool_base.include?(t_base)
|
93
|
+
end
|
94
|
+
|
95
|
+
if suggestions.any?
|
96
|
+
"Did you mean: #{suggestions.join(", ")}?"
|
97
|
+
else
|
98
|
+
"Ensure the tool module exists or check the tool name spelling"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
def extract_all_tools(tools_config)
|
103
|
+
tools = []
|
104
|
+
tools_config.each do |tool_entry|
|
105
|
+
case tool_entry
|
106
|
+
when String
|
107
|
+
tools << tool_entry
|
108
|
+
when Hash
|
109
|
+
tool_entry.each_key do |tool_name|
|
110
|
+
tools << tool_name
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
114
|
+
tools
|
115
|
+
end
|
116
|
+
|
117
|
+
def extract_step_name(step)
|
118
|
+
case step
|
119
|
+
when String
|
120
|
+
step
|
121
|
+
when Hash
|
122
|
+
# Complex step types don't have simple names
|
123
|
+
nil
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def check_step_references_in_config(config, valid_step_names)
|
128
|
+
# Check steps array
|
129
|
+
["steps", "pre_processing", "post_processing"].each do |key|
|
130
|
+
if config[key].is_a?(Array)
|
131
|
+
check_step_references(config[key], valid_step_names)
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
135
|
+
|
136
|
+
def check_step_references(steps, valid_step_names, path = [])
|
137
|
+
steps.each_with_index do |step, index|
|
138
|
+
current_path = path + [index]
|
139
|
+
|
140
|
+
case step
|
141
|
+
when Hash
|
142
|
+
# Check conditions that might reference steps
|
143
|
+
["if", "unless", "case"].each do |condition_key|
|
144
|
+
next unless step[condition_key]
|
145
|
+
|
146
|
+
condition = step[condition_key]
|
147
|
+
next unless condition.is_a?(String) && !condition.include?("{{") && !condition.include?("$(")
|
148
|
+
|
149
|
+
# Check if it looks like a step reference (snake_case identifier)
|
150
|
+
# and is not a known boolean value
|
151
|
+
next unless condition.match?(/^[a-z_]+$/) && !["true", "false", "yes", "no", "on", "off"].include?(condition)
|
152
|
+
|
153
|
+
# This looks like it could be a step reference
|
154
|
+
next if valid_step_names.include?(condition)
|
155
|
+
|
156
|
+
add_error(
|
157
|
+
type: :step_reference,
|
158
|
+
message: "Step '#{condition}' referenced in #{condition_key} condition does not exist",
|
159
|
+
suggestion: "Ensure step '#{condition}' is defined before it's referenced",
|
160
|
+
)
|
161
|
+
end
|
162
|
+
|
163
|
+
# Check nested steps
|
164
|
+
["then", "else", "steps"].each do |key|
|
165
|
+
if step[key].is_a?(Array)
|
166
|
+
check_step_references(step[key], valid_step_names, current_path + [key])
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
# Check case/when branches
|
171
|
+
if step["when"].is_a?(Hash)
|
172
|
+
step["when"].each do |when_value, when_steps|
|
173
|
+
if when_steps.is_a?(Array)
|
174
|
+
check_step_references(when_steps, valid_step_names, current_path + ["when", when_value])
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
when Array
|
179
|
+
check_step_references(step, valid_step_names, current_path)
|
180
|
+
end
|
181
|
+
end
|
182
|
+
end
|
183
|
+
|
184
|
+
def validate_target_resource(target)
|
185
|
+
return unless @workflow_path
|
186
|
+
|
187
|
+
workflow_dir = File.dirname(@workflow_path)
|
188
|
+
|
189
|
+
# If target is a glob pattern or shell command, skip file validation
|
190
|
+
return if target.include?("*") || target.start_with?("$(")
|
191
|
+
|
192
|
+
target_path = File.expand_path(target, workflow_dir)
|
193
|
+
unless File.exist?(target_path)
|
194
|
+
add_warning(
|
195
|
+
type: :resource,
|
196
|
+
message: "Target file '#{target}' does not exist",
|
197
|
+
suggestion: "Ensure the target file exists or use a glob pattern",
|
198
|
+
)
|
199
|
+
end
|
200
|
+
end
|
201
|
+
|
202
|
+
def validate_prompt_files
|
203
|
+
workflow_dir = File.dirname(@workflow_path)
|
204
|
+
all_steps = @step_collector.all_steps
|
205
|
+
|
206
|
+
all_steps.each do |step|
|
207
|
+
next unless step.is_a?(String)
|
208
|
+
|
209
|
+
# Check if corresponding prompt file exists
|
210
|
+
prompt_path = File.join(workflow_dir, step, "prompt.md")
|
211
|
+
next if File.exist?(prompt_path)
|
212
|
+
|
213
|
+
add_warning(
|
214
|
+
type: :resource,
|
215
|
+
message: "Prompt file missing for step '#{step}'",
|
216
|
+
suggestion: "Create file: #{prompt_path}",
|
217
|
+
)
|
218
|
+
end
|
219
|
+
end
|
220
|
+
end
|
221
|
+
end
|
222
|
+
end
|
223
|
+
end
|
@@ -0,0 +1,113 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
module Validators
|
6
|
+
# Validates workflow configuration for best practices and common issues
|
7
|
+
class LintingValidator < BaseValidator
|
8
|
+
# Configurable thresholds
|
9
|
+
MAX_STEPS = 20
|
10
|
+
MAX_NESTING_DEPTH = 5
|
11
|
+
|
12
|
+
def initialize(parsed_yaml, workflow_path = nil, step_collector: nil)
|
13
|
+
super(parsed_yaml, workflow_path)
|
14
|
+
@step_collector = step_collector || StepCollector.new(parsed_yaml)
|
15
|
+
end
|
16
|
+
|
17
|
+
def validate
|
18
|
+
lint_naming_conventions
|
19
|
+
lint_step_complexity
|
20
|
+
lint_common_mistakes
|
21
|
+
end
|
22
|
+
|
23
|
+
private
|
24
|
+
|
25
|
+
def lint_naming_conventions
|
26
|
+
# Check workflow name
|
27
|
+
if @parsed_yaml["name"].nil? || @parsed_yaml["name"].empty?
|
28
|
+
add_warning(
|
29
|
+
type: :naming,
|
30
|
+
message: "Workflow should have a descriptive name",
|
31
|
+
suggestion: "Add a 'name' field to your workflow configuration",
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Check step naming
|
36
|
+
all_steps = @step_collector.all_steps
|
37
|
+
all_steps.each do |step|
|
38
|
+
next unless step.is_a?(String) && !step.match?(/^[a-z_]+$/)
|
39
|
+
|
40
|
+
add_warning(
|
41
|
+
type: :naming,
|
42
|
+
step: step,
|
43
|
+
message: "Step name '#{step}' should use snake_case",
|
44
|
+
suggestion: "Rename to '#{step.downcase.gsub(/[^a-z0-9]/, "_")}'",
|
45
|
+
)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def lint_step_complexity
|
50
|
+
# Check for overly complex workflows
|
51
|
+
all_steps = @step_collector.all_steps
|
52
|
+
if all_steps.size > MAX_STEPS
|
53
|
+
add_warning(
|
54
|
+
type: :complexity,
|
55
|
+
message: "Workflow has #{all_steps.size} steps, consider breaking it into smaller workflows",
|
56
|
+
suggestion: "Use sub-workflows or modularize complex logic",
|
57
|
+
)
|
58
|
+
end
|
59
|
+
|
60
|
+
# Check for deeply nested conditions
|
61
|
+
check_nesting_depth(@parsed_yaml["steps"] || [])
|
62
|
+
end
|
63
|
+
|
64
|
+
def lint_common_mistakes
|
65
|
+
# Missing error handling
|
66
|
+
if !@parsed_yaml["exit_on_error"] && !error_handling?
|
67
|
+
add_warning(
|
68
|
+
type: :error_handling,
|
69
|
+
message: "No error handling configured",
|
70
|
+
suggestion: "Consider adding 'exit_on_error: true' or error handling steps",
|
71
|
+
)
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def check_nesting_depth(steps, depth = 0)
|
76
|
+
steps.each do |step|
|
77
|
+
next unless step.is_a?(Hash)
|
78
|
+
|
79
|
+
current_depth = depth + 1
|
80
|
+
|
81
|
+
if current_depth > MAX_NESTING_DEPTH
|
82
|
+
add_warning(
|
83
|
+
type: :complexity,
|
84
|
+
message: "Excessive nesting depth (#{current_depth} levels)",
|
85
|
+
suggestion: "Consider extracting nested logic into separate steps or workflows",
|
86
|
+
)
|
87
|
+
end
|
88
|
+
|
89
|
+
# Check nested steps
|
90
|
+
["steps", "then", "else", "true", "false"].each do |key|
|
91
|
+
check_nesting_depth(step[key], current_depth) if step[key].is_a?(Array)
|
92
|
+
end
|
93
|
+
|
94
|
+
# Check case/when branches
|
95
|
+
next unless step["when"]
|
96
|
+
|
97
|
+
step["when"].each_value do |when_steps|
|
98
|
+
check_nesting_depth(when_steps, current_depth) if when_steps.is_a?(Array)
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
def error_handling?
|
104
|
+
# Check if workflow has any error handling mechanisms
|
105
|
+
all_steps = @step_collector.all_steps
|
106
|
+
all_steps.any? do |step|
|
107
|
+
step.is_a?(Hash) && (step["rescue"] || step["ensure"])
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,90 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
module Validators
|
6
|
+
# Validates workflow configuration against JSON schema
|
7
|
+
class SchemaValidator < BaseValidator
|
8
|
+
attr_reader :parsed_yaml
|
9
|
+
|
10
|
+
def initialize(yaml_content, workflow_path = nil) # rubocop:disable Lint/MissingSuper
|
11
|
+
@yaml_content = yaml_content&.strip || ""
|
12
|
+
@workflow_path = workflow_path
|
13
|
+
@errors = []
|
14
|
+
@warnings = []
|
15
|
+
|
16
|
+
begin
|
17
|
+
@parsed_yaml = @yaml_content.empty? ? {} : YAML.safe_load(@yaml_content)
|
18
|
+
rescue Psych::SyntaxError => e
|
19
|
+
@errors << format_yaml_error(e)
|
20
|
+
@parsed_yaml = {}
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def validate
|
25
|
+
if @parsed_yaml.empty?
|
26
|
+
@errors << {
|
27
|
+
type: :empty_configuration,
|
28
|
+
message: "Workflow configuration is empty",
|
29
|
+
suggestion: "Provide a valid workflow configuration with required fields: name, tools, and steps",
|
30
|
+
}
|
31
|
+
return
|
32
|
+
end
|
33
|
+
|
34
|
+
validator = Validator.new(@yaml_content)
|
35
|
+
unless validator.valid?
|
36
|
+
validator.errors.each do |error|
|
37
|
+
@errors << format_schema_error(error)
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
private
|
43
|
+
|
44
|
+
def format_yaml_error(error)
|
45
|
+
{
|
46
|
+
type: :yaml_syntax,
|
47
|
+
message: "YAML syntax error: #{error.message}",
|
48
|
+
line: error.line,
|
49
|
+
column: error.column,
|
50
|
+
suggestion: "Check YAML syntax at line #{error.line}, column #{error.column}",
|
51
|
+
}
|
52
|
+
end
|
53
|
+
|
54
|
+
def format_schema_error(error)
|
55
|
+
# Parse JSON Schema error and make it more user-friendly
|
56
|
+
if error.include?("did not contain a required property")
|
57
|
+
# Extract property name from error
|
58
|
+
match = error.match(/required property of '([^']+)'/)
|
59
|
+
if match
|
60
|
+
property = match[1]
|
61
|
+
{
|
62
|
+
type: :schema,
|
63
|
+
message: "Missing required field: '#{property}'",
|
64
|
+
suggestion: "Add '#{property}' to your workflow configuration",
|
65
|
+
}
|
66
|
+
else
|
67
|
+
{
|
68
|
+
type: :schema,
|
69
|
+
message: error,
|
70
|
+
suggestion: "Check the required fields in your workflow configuration",
|
71
|
+
}
|
72
|
+
end
|
73
|
+
elsif error.include?("does not match")
|
74
|
+
{
|
75
|
+
type: :schema,
|
76
|
+
message: error,
|
77
|
+
suggestion: "Check the workflow schema documentation for valid values",
|
78
|
+
}
|
79
|
+
else
|
80
|
+
{
|
81
|
+
type: :schema,
|
82
|
+
message: error,
|
83
|
+
suggestion: "Refer to the workflow schema for correct configuration structure",
|
84
|
+
}
|
85
|
+
end
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
@@ -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
|
-
|
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:
|
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
|
@@ -10,6 +10,7 @@ module Roast
|
|
10
10
|
|
11
11
|
def setup
|
12
12
|
load_roast_initializers
|
13
|
+
check_raix_configuration
|
13
14
|
include_tools
|
14
15
|
configure_api_client
|
15
16
|
end
|
@@ -20,6 +21,85 @@ module Roast
|
|
20
21
|
Roast::Initializers.load_all
|
21
22
|
end
|
22
23
|
|
24
|
+
def check_raix_configuration
|
25
|
+
# Skip check in test environment
|
26
|
+
return if ENV["RAILS_ENV"] == "test" || ENV["RACK_ENV"] == "test" || defined?(Minitest)
|
27
|
+
|
28
|
+
# Only check if the workflow has steps that would need API access
|
29
|
+
return if @configuration.steps.empty?
|
30
|
+
|
31
|
+
# Check if Raix has been configured with the appropriate client
|
32
|
+
case @configuration.api_provider
|
33
|
+
when :openai
|
34
|
+
if Raix.configuration.openai_client.nil?
|
35
|
+
warn_about_missing_raix_configuration(:openai)
|
36
|
+
end
|
37
|
+
when :openrouter
|
38
|
+
if Raix.configuration.openrouter_client.nil?
|
39
|
+
warn_about_missing_raix_configuration(:openrouter)
|
40
|
+
end
|
41
|
+
when nil
|
42
|
+
# If no api_provider is set but we have steps that might need API access,
|
43
|
+
# check if any client is configured
|
44
|
+
if Raix.configuration.openai_client.nil? && Raix.configuration.openrouter_client.nil?
|
45
|
+
warn_about_missing_raix_configuration(:any)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
def warn_about_missing_raix_configuration(provider)
|
51
|
+
::CLI::UI.frame_style = :box
|
52
|
+
::CLI::UI::Frame.open("{{red:Raix Configuration Missing}}", color: :red) do
|
53
|
+
case provider
|
54
|
+
when :openai
|
55
|
+
puts ::CLI::UI.fmt("{{yellow:⚠️ Warning: Raix OpenAI client is not configured!}}")
|
56
|
+
when :openrouter
|
57
|
+
puts ::CLI::UI.fmt("{{yellow:⚠️ Warning: Raix OpenRouter client is not configured!}}")
|
58
|
+
else
|
59
|
+
puts ::CLI::UI.fmt("{{yellow:⚠️ Warning: Raix is not configured!}}")
|
60
|
+
end
|
61
|
+
puts
|
62
|
+
puts "Roast requires Raix to be properly initialized to make API calls."
|
63
|
+
puts ::CLI::UI.fmt("To fix this, create a file at {{cyan:.roast/initializers/raix.rb}} with:")
|
64
|
+
puts
|
65
|
+
puts ::CLI::UI.fmt("{{cyan:# frozen_string_literal: true}}")
|
66
|
+
puts
|
67
|
+
puts ::CLI::UI.fmt("{{cyan:require \"raix\"}}")
|
68
|
+
|
69
|
+
if provider == :openrouter
|
70
|
+
puts ::CLI::UI.fmt("{{cyan:require \"open_router\"}}")
|
71
|
+
puts
|
72
|
+
puts ::CLI::UI.fmt("{{cyan:Raix.configure do |config|}}")
|
73
|
+
puts ::CLI::UI.fmt("{{cyan: config.openrouter_client = OpenRouter::Client.new(}}")
|
74
|
+
puts ::CLI::UI.fmt("{{cyan: access_token: ENV.fetch(\"OPENROUTER_API_KEY\"),}}")
|
75
|
+
puts ::CLI::UI.fmt("{{cyan: uri_base: \"https://openrouter.ai/api/v1\",}}")
|
76
|
+
puts ::CLI::UI.fmt("{{cyan: )}}")
|
77
|
+
else
|
78
|
+
puts ::CLI::UI.fmt("{{cyan:require \"faraday\"}}")
|
79
|
+
puts ::CLI::UI.fmt("{{cyan:require \"faraday/retry\"}}")
|
80
|
+
puts
|
81
|
+
puts ::CLI::UI.fmt("{{cyan: Raix.configure do |config|}}")
|
82
|
+
puts ::CLI::UI.fmt("{{cyan: config.openai_client = OpenAI::Client.new(}}")
|
83
|
+
puts ::CLI::UI.fmt("{{cyan: access_token: ENV.fetch(\"OPENAI_API_KEY\"),}}")
|
84
|
+
puts ::CLI::UI.fmt("{{cyan: uri_base: \"https://api.openai.com/v1\",}}")
|
85
|
+
puts ::CLI::UI.fmt("{{cyan: ) do |f|}}")
|
86
|
+
puts ::CLI::UI.fmt("{{cyan: f.request(:retry, {}}")
|
87
|
+
puts ::CLI::UI.fmt("{{cyan: max: 2,}}")
|
88
|
+
puts ::CLI::UI.fmt("{{cyan: interval: 0.05,}}")
|
89
|
+
puts ::CLI::UI.fmt("{{cyan: interval_randomness: 0.5,}}")
|
90
|
+
puts ::CLI::UI.fmt("{{cyan: backoff_factor: 2,}}")
|
91
|
+
puts ::CLI::UI.fmt("{{cyan: })}}")
|
92
|
+
puts ::CLI::UI.fmt("{{cyan: end}}")
|
93
|
+
end
|
94
|
+
puts ::CLI::UI.fmt("{{cyan:end}}")
|
95
|
+
puts
|
96
|
+
puts "For Shopify users, you need to use the LLM gateway proxy instead."
|
97
|
+
puts "Check the #roast slack channel for more information."
|
98
|
+
puts
|
99
|
+
end
|
100
|
+
raise ::CLI::Kit::Abort, "Please configure Raix before running workflows."
|
101
|
+
end
|
102
|
+
|
23
103
|
def include_tools
|
24
104
|
return unless @configuration.tools.present? || @configuration.mcp_tools.present?
|
25
105
|
|
@@ -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
|
|