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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yaml +2 -2
  3. data/.gitignore +1 -0
  4. data/CHANGELOG.md +103 -0
  5. data/CLAUDE.md +55 -9
  6. data/Gemfile.lock +19 -10
  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/rb_test_runner +1 -1
  45. data/examples/grading/workflow.png +0 -0
  46. data/examples/interpolation/workflow.png +0 -0
  47. data/examples/interpolation/workflow.yml +1 -1
  48. data/examples/iteration/workflow.png +0 -0
  49. data/examples/json_handling/workflow.png +0 -0
  50. data/examples/mcp/database_workflow.png +0 -0
  51. data/examples/mcp/env_demo/workflow.png +0 -0
  52. data/examples/mcp/filesystem_demo/workflow.png +0 -0
  53. data/examples/mcp/github_workflow.png +0 -0
  54. data/examples/mcp/multi_mcp_workflow.png +0 -0
  55. data/examples/mcp/workflow.png +0 -0
  56. data/examples/no_model_fallback/README.md +17 -0
  57. data/examples/no_model_fallback/analyze_file/prompt.md +1 -0
  58. data/examples/no_model_fallback/analyze_patterns/prompt.md +27 -0
  59. data/examples/no_model_fallback/generate_report_for_md/prompt.md +10 -0
  60. data/examples/no_model_fallback/generate_report_for_rb/prompt.md +3 -0
  61. data/examples/no_model_fallback/sample.rb +42 -0
  62. data/examples/no_model_fallback/workflow.yml +19 -0
  63. data/examples/openrouter_example/workflow.png +0 -0
  64. data/examples/pre_post_processing/workflow.png +0 -0
  65. data/examples/rspec_to_minitest/workflow.png +0 -0
  66. data/examples/shared_config/example_with_shared_config/workflow.png +0 -0
  67. data/examples/shared_config/shared.png +0 -0
  68. data/examples/single_target_prepost/workflow.png +0 -0
  69. data/examples/smart_coercion_defaults/workflow.png +0 -0
  70. data/examples/step_configuration/workflow.png +0 -0
  71. data/examples/swarm_example.yml +25 -0
  72. data/examples/tool_config_example/workflow.png +0 -0
  73. data/examples/user_input/funny_name/workflow.png +0 -0
  74. data/examples/user_input/simple_input_demo/workflow.png +0 -0
  75. data/examples/user_input/survey_workflow.png +0 -0
  76. data/examples/user_input/workflow.png +0 -0
  77. data/examples/workflow_generator/workflow.png +0 -0
  78. data/lib/roast/errors.rb +3 -0
  79. data/lib/roast/helpers/timeout_handler.rb +91 -0
  80. data/lib/roast/services/context_threshold_checker.rb +42 -0
  81. data/lib/roast/services/token_counting_service.rb +44 -0
  82. data/lib/roast/tools/apply_diff.rb +128 -0
  83. data/lib/roast/tools/bash.rb +15 -9
  84. data/lib/roast/tools/cmd.rb +32 -12
  85. data/lib/roast/tools/coding_agent.rb +65 -10
  86. data/lib/roast/tools/context_summarizer.rb +108 -0
  87. data/lib/roast/tools/swarm.rb +124 -0
  88. data/lib/roast/version.rb +1 -1
  89. data/lib/roast/workflow/agent_step.rb +9 -2
  90. data/lib/roast/workflow/base_iteration_step.rb +3 -2
  91. data/lib/roast/workflow/base_workflow.rb +41 -2
  92. data/lib/roast/workflow/command_executor.rb +3 -1
  93. data/lib/roast/workflow/configuration.rb +2 -1
  94. data/lib/roast/workflow/configuration_loader.rb +63 -1
  95. data/lib/roast/workflow/configuration_parser.rb +2 -0
  96. data/lib/roast/workflow/context_manager.rb +89 -0
  97. data/lib/roast/workflow/each_step.rb +1 -1
  98. data/lib/roast/workflow/input_step.rb +2 -0
  99. data/lib/roast/workflow/interpolator.rb +23 -1
  100. data/lib/roast/workflow/output_handler.rb +1 -1
  101. data/lib/roast/workflow/repeat_step.rb +1 -1
  102. data/lib/roast/workflow/replay_handler.rb +1 -1
  103. data/lib/roast/workflow/sqlite_state_repository.rb +342 -0
  104. data/lib/roast/workflow/state_manager.rb +2 -2
  105. data/lib/roast/workflow/state_repository_factory.rb +36 -0
  106. data/lib/roast/workflow/step_completion_reporter.rb +27 -0
  107. data/lib/roast/workflow/step_executor_coordinator.rb +19 -18
  108. data/lib/roast/workflow/step_executor_with_reporting.rb +68 -0
  109. data/lib/roast/workflow/step_loader.rb +1 -1
  110. data/lib/roast/workflow/step_name_extractor.rb +84 -0
  111. data/lib/roast/workflow/validation_command.rb +197 -0
  112. data/lib/roast/workflow/validators/base_validator.rb +44 -0
  113. data/lib/roast/workflow/validators/dependency_validator.rb +223 -0
  114. data/lib/roast/workflow/validators/linting_validator.rb +113 -0
  115. data/lib/roast/workflow/validators/schema_validator.rb +90 -0
  116. data/lib/roast/workflow/validators/step_collector.rb +57 -0
  117. data/lib/roast/workflow/validators/validation_orchestrator.rb +52 -0
  118. data/lib/roast/workflow/workflow_executor.rb +11 -4
  119. data/lib/roast/workflow/workflow_initializer.rb +80 -0
  120. data/lib/roast/workflow/workflow_runner.rb +6 -0
  121. data/lib/roast/workflow_diagram_generator.rb +298 -0
  122. data/lib/roast.rb +158 -0
  123. data/roast.gemspec +4 -1
  124. data/schema/workflow.json +77 -1
  125. metadata +129 -1
@@ -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
@@ -25,11 +25,13 @@ require "active_support/core_ext/string/inflections"
25
25
  require "active_support/isolated_execution_state"
26
26
  require "active_support/notifications"
27
27
  require "cli/ui"
28
+ require "cli/kit"
28
29
  require "diff/lcs"
29
30
  require "json-schema"
30
31
  require "raix"
31
32
  require "raix/chat_completion"
32
33
  require "raix/function_dispatch"
34
+ require "ruby-graphviz"
33
35
  require "thor"
34
36
 
35
37
  # Autoloading setup
@@ -50,6 +52,7 @@ module Roast
50
52
  option :target, type: :string, aliases: "-t", desc: "Override target files. Can be file path, glob pattern, or $(shell command)"
51
53
  option :replay, type: :string, aliases: "-r", desc: "Resume workflow from a specific step. Format: step_name or session_timestamp:step_name"
52
54
  option :pause, type: :string, aliases: "-p", desc: "Pause workflow after a specific step. Format: step_name"
55
+ option :file_storage, type: :boolean, aliases: "-f", desc: "Use filesystem storage for sessions instead of SQLite"
53
56
 
54
57
  def execute(*paths)
55
58
  raise Thor::Error, "Workflow configuration file is required" if paths.empty?
@@ -67,6 +70,44 @@ module Roast
67
70
  Roast::Workflow::ConfigurationParser.new(expanded_workflow_path, files, options.transform_keys(&:to_sym)).begin!
68
71
  end
69
72
 
73
+ desc "resume WORKFLOW_FILE", "Resume a paused workflow with an event"
74
+ option :event, type: :string, aliases: "-e", required: true, desc: "Event name to trigger"
75
+ option :session_id, type: :string, aliases: "-s", desc: "Specific session ID to resume (defaults to most recent)"
76
+ option :event_data, type: :string, desc: "JSON data to pass with the event"
77
+ def resume(workflow_path)
78
+ expanded_workflow_path = if workflow_path.include?("workflow.yml")
79
+ File.expand_path(workflow_path)
80
+ else
81
+ File.expand_path("roast/#{workflow_path}/workflow.yml")
82
+ end
83
+
84
+ unless File.exist?(expanded_workflow_path)
85
+ raise Thor::Error, "Workflow file not found: #{expanded_workflow_path}"
86
+ end
87
+
88
+ # Store the event in the session
89
+ repository = Workflow::StateRepositoryFactory.create
90
+
91
+ unless repository.respond_to?(:add_event)
92
+ raise Thor::Error, "Event resumption requires SQLite storage. Set ROAST_STATE_STORAGE=sqlite"
93
+ end
94
+
95
+ # Parse event data if provided
96
+ event_data = options[:event_data] ? JSON.parse(options[:event_data]) : nil
97
+
98
+ # Add the event to the session
99
+ session_id = options[:session_id]
100
+ repository.add_event(expanded_workflow_path, session_id, options[:event], event_data)
101
+
102
+ # Resume workflow execution from the wait state
103
+ resume_options = options.transform_keys(&:to_sym).merge(
104
+ resume_from_event: options[:event],
105
+ session_id: session_id,
106
+ )
107
+
108
+ Roast::Workflow::ConfigurationParser.new(expanded_workflow_path, [], resume_options).begin!
109
+ end
110
+
70
111
  desc "version", "Display the current version of Roast"
71
112
  def version
72
113
  puts "Roast version #{Roast::VERSION}"
@@ -108,6 +149,123 @@ module Roast
108
149
  puts "Run a workflow with: roast execute <workflow_name>"
109
150
  end
110
151
 
152
+ desc "validate [WORKFLOW_CONFIGURATION_FILE]", "Validate a workflow configuration"
153
+ option :strict, type: :boolean, aliases: "-s", desc: "Treat warnings as errors"
154
+ def validate(workflow_path = nil)
155
+ validation_command = Roast::Workflow::ValidationCommand.new(options)
156
+ validation_command.execute(workflow_path)
157
+ end
158
+
159
+ desc "sessions", "List stored workflow sessions"
160
+ option :status, type: :string, aliases: "-s", desc: "Filter by status (running, waiting, completed, failed)"
161
+ option :workflow, type: :string, aliases: "-w", desc: "Filter by workflow name"
162
+ option :older_than, type: :string, desc: "Show sessions older than specified time (e.g., '7d', '1h')"
163
+ option :cleanup, type: :boolean, desc: "Clean up old sessions"
164
+ def sessions
165
+ repository = Workflow::StateRepositoryFactory.create
166
+
167
+ unless repository.respond_to?(:list_sessions)
168
+ raise Thor::Error, "Session listing is only available with SQLite storage. Set ROAST_STATE_STORAGE=sqlite"
169
+ end
170
+
171
+ if options[:cleanup] && options[:older_than]
172
+ count = repository.cleanup_old_sessions(options[:older_than])
173
+ puts "Cleaned up #{count} old sessions"
174
+ return
175
+ end
176
+
177
+ sessions = repository.list_sessions(
178
+ status: options[:status],
179
+ workflow_name: options[:workflow],
180
+ older_than: options[:older_than],
181
+ )
182
+
183
+ if sessions.empty?
184
+ puts "No sessions found"
185
+ return
186
+ end
187
+
188
+ puts "Found #{sessions.length} session(s):"
189
+ puts
190
+
191
+ sessions.each do |session|
192
+ id, workflow_name, _, status, current_step, created_at, updated_at = session
193
+
194
+ puts "Session: #{id}"
195
+ puts " Workflow: #{workflow_name}"
196
+ puts " Status: #{status}"
197
+ puts " Current step: #{current_step || "N/A"}"
198
+ puts " Created: #{created_at}"
199
+ puts " Updated: #{updated_at}"
200
+ puts
201
+ end
202
+ end
203
+
204
+ desc "session SESSION_ID", "Show details for a specific session"
205
+ def session(session_id)
206
+ repository = Workflow::StateRepositoryFactory.create
207
+
208
+ unless repository.respond_to?(:get_session_details)
209
+ raise Thor::Error, "Session details are only available with SQLite storage. Set ROAST_STATE_STORAGE=sqlite"
210
+ end
211
+
212
+ details = repository.get_session_details(session_id)
213
+
214
+ unless details
215
+ raise Thor::Error, "Session not found: #{session_id}"
216
+ end
217
+
218
+ session = details[:session]
219
+ states = details[:states]
220
+ events = details[:events]
221
+
222
+ puts "Session: #{session[0]}"
223
+ puts "Workflow: #{session[1]}"
224
+ puts "Path: #{session[2]}"
225
+ puts "Status: #{session[3]}"
226
+ puts "Created: #{session[6]}"
227
+ puts "Updated: #{session[7]}"
228
+
229
+ if session[5]
230
+ puts
231
+ puts "Final output:"
232
+ puts session[5]
233
+ end
234
+
235
+ if states && !states.empty?
236
+ puts
237
+ puts "Steps executed:"
238
+ states.each do |step_index, step_name, created_at|
239
+ puts " #{step_index}: #{step_name} (#{created_at})"
240
+ end
241
+ end
242
+
243
+ if events && !events.empty?
244
+ puts
245
+ puts "Events:"
246
+ events.each do |event_name, event_data, received_at|
247
+ puts " #{event_name} at #{received_at}"
248
+ puts " Data: #{event_data}" if event_data
249
+ end
250
+ end
251
+ end
252
+
253
+ desc "diagram WORKFLOW_FILE", "Generate a visual diagram of a workflow"
254
+ option :output, type: :string, aliases: "-o", desc: "Output file path (defaults to workflow_name_diagram.png)"
255
+ def diagram(workflow_file)
256
+ unless File.exist?(workflow_file)
257
+ raise Thor::Error, "Workflow file not found: #{workflow_file}"
258
+ end
259
+
260
+ workflow = Workflow::Configuration.new(workflow_file)
261
+ generator = WorkflowDiagramGenerator.new(workflow, workflow_file)
262
+ output_path = generator.generate(options[:output])
263
+
264
+ puts ::CLI::UI.fmt("{{success:✓}} Diagram generated: #{output_path}")
265
+ rescue StandardError => e
266
+ raise Thor::Error, "Error generating diagram: #{e.message}"
267
+ end
268
+
111
269
  private
112
270
 
113
271
  def show_example_picker
data/roast.gemspec CHANGED
@@ -30,19 +30,22 @@ 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) }
37
37
  spec.require_paths = ["lib"]
38
38
 
39
39
  spec.add_dependency("activesupport", ">= 7.0")
40
+ spec.add_dependency("cli-kit", "~> 5.0")
40
41
  spec.add_dependency("cli-ui", "2.3.0")
41
42
  spec.add_dependency("diff-lcs", "~> 1.5")
42
43
  spec.add_dependency("faraday-retry")
43
44
  spec.add_dependency("json-schema")
44
45
  spec.add_dependency("open_router", "~> 0.3")
45
46
  spec.add_dependency("raix", "~> 1.0")
47
+ spec.add_dependency("ruby-graphviz", "~> 1.2")
48
+ spec.add_dependency("sqlite3", "~> 2.6")
46
49
  spec.add_dependency("thor", "~> 1.3")
47
50
  spec.add_dependency("zeitwerk", "~> 2.6")
48
51
  end
data/schema/workflow.json CHANGED
@@ -8,7 +8,46 @@
8
8
  "tools": {
9
9
  "type": "array",
10
10
  "items": {
11
- "type": "string"
11
+ "oneOf": [
12
+ {
13
+ "type": "string"
14
+ },
15
+ {
16
+ "type": "object",
17
+ "additionalProperties": {
18
+ "type": "object",
19
+ "properties": {
20
+ "url": {
21
+ "type": "string"
22
+ },
23
+ "command": {
24
+ "type": "string"
25
+ },
26
+ "args": {
27
+ "type": "array",
28
+ "items": {
29
+ "type": "string"
30
+ }
31
+ },
32
+ "env": {
33
+ "type": "object"
34
+ },
35
+ "only": {
36
+ "type": "array",
37
+ "items": {
38
+ "type": "string"
39
+ }
40
+ },
41
+ "except": {
42
+ "type": "array",
43
+ "items": {
44
+ "type": "string"
45
+ }
46
+ }
47
+ }
48
+ }
49
+ }
50
+ ]
12
51
  }
13
52
  },
14
53
  "target": {
@@ -23,6 +62,43 @@
23
62
  "type": "string",
24
63
  "description": "Default AI model to use for all steps in the workflow"
25
64
  },
65
+ "context_management": {
66
+ "type": "object",
67
+ "description": "Configuration for automatic context management and compaction",
68
+ "properties": {
69
+ "enabled": {
70
+ "type": "boolean",
71
+ "description": "Whether to enable context management",
72
+ "default": true
73
+ },
74
+ "strategy": {
75
+ "type": "string",
76
+ "description": "Compaction strategy to use when threshold is exceeded",
77
+ "enum": ["auto", "summarize", "prune", "none"],
78
+ "default": "auto"
79
+ },
80
+ "threshold": {
81
+ "type": "number",
82
+ "description": "Percentage of context window to trigger compaction (0.0 to 1.0)",
83
+ "minimum": 0.0,
84
+ "maximum": 1.0,
85
+ "default": 0.8
86
+ },
87
+ "max_tokens": {
88
+ "type": "integer",
89
+ "description": "Maximum number of tokens allowed in context (defaults to model's limit)",
90
+ "minimum": 1000
91
+ },
92
+ "retain_steps": {
93
+ "type": "array",
94
+ "description": "Step names to always keep in full when compacting",
95
+ "items": {
96
+ "type": "string"
97
+ }
98
+ }
99
+ },
100
+ "additionalProperties": false
101
+ },
26
102
  "inputs": {
27
103
  "type": "array",
28
104
  "items": {