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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yaml +2 -2
- data/CHANGELOG.md +65 -0
- data/CLAUDE.md +55 -9
- data/Gemfile +1 -0
- data/Gemfile.lock +8 -1
- 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/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/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 +64 -9
- 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/configuration.rb +2 -1
- data/lib/roast/workflow/configuration_loader.rb +63 -1
- data/lib/roast/workflow/context_manager.rb +89 -0
- data/lib/roast/workflow/each_step.rb +1 -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_runner.rb +6 -0
- data/lib/roast/workflow_diagram_generator.rb +298 -0
- data/lib/roast.rb +157 -0
- data/roast.gemspec +2 -1
- data/schema/workflow.json +77 -1
- 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
|
-
|
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
|
@@ -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
|