|
35 | 35 | require "raix/chat_completion"
|
36 | 36 | require "raix/function_dispatch"
|
37 | 37 | require "ruby-graphviz"
|
38 |
| -require "thor" |
39 | 38 | require "timeout"
|
40 | 39 |
|
41 | 40 | # Autoloading setup
|
|
48 | 47 |
|
49 | 48 | module Roast
|
50 | 49 | ROOT = File.expand_path("../..", __FILE__)
|
51 |
| - |
52 |
| - class CLI < Thor |
53 |
| - desc "execute [WORKFLOW_CONFIGURATION_FILE] [FILES...]", "Run a configured workflow" |
54 |
| - option :concise, type: :boolean, aliases: "-c", desc: "Optional flag for use in output templates" |
55 |
| - option :output, type: :string, aliases: "-o", desc: "Save results to a file" |
56 |
| - option :verbose, type: :boolean, aliases: "-v", desc: "Show output from all steps as they are executed" |
57 |
| - option :target, type: :string, aliases: "-t", desc: "Override target files. Can be file path, glob pattern, or $(shell command)" |
58 |
| - option :replay, type: :string, aliases: "-r", desc: "Resume workflow from a specific step. Format: step_name or session_timestamp:step_name" |
59 |
| - option :pause, type: :string, aliases: "-p", desc: "Pause workflow after a specific step. Format: step_name" |
60 |
| - option :file_storage, type: :boolean, aliases: "-f", desc: "Use filesystem storage for sessions instead of SQLite" |
61 |
| - option :executor, type: :string, default: "default", desc: "Set workflow executor - experimental syntax" |
62 |
| - |
63 |
| - def execute(*paths) |
64 |
| - raise Thor::Error, "Workflow configuration file is required" if paths.empty? |
65 |
| - |
66 |
| - workflow_path, *files = paths |
67 |
| - |
68 |
| - if options[:executor] == "dsl" |
69 |
| - puts "⚠️ WARNING: This is an experimental syntax and may break at any time. Don't depend on it." |
70 |
| - Roast::DSL::Executor.from_file(workflow_path) |
71 |
| - else |
72 |
| - expanded_workflow_path = if workflow_path.include?("workflow.yml") |
73 |
| - File.expand_path(workflow_path) |
74 |
| - else |
75 |
| - File.expand_path("roast/#{workflow_path}/workflow.yml") |
76 |
| - end |
77 |
| - |
78 |
| - raise Thor::Error, "Expected a Roast workflow configuration file, got directory: #{expanded_workflow_path}" if File.directory?(expanded_workflow_path) |
79 |
| - |
80 |
| - Roast::Workflow::WorkflowRunner.new(expanded_workflow_path, files, options.transform_keys(&:to_sym)).begin! |
81 |
| - end |
82 |
| - rescue => e |
83 |
| - if options[:verbose] |
84 |
| - raise e |
85 |
| - else |
86 |
| - $stderr.puts e.message |
87 |
| - end |
88 |
| - end |
89 |
| - |
90 |
| - desc "resume WORKFLOW_FILE", "Resume a paused workflow with an event" |
91 |
| - option :event, type: :string, aliases: "-e", required: true, desc: "Event name to trigger" |
92 |
| - option :session_id, type: :string, aliases: "-s", desc: "Specific session ID to resume (defaults to most recent)" |
93 |
| - option :event_data, type: :string, desc: "JSON data to pass with the event" |
94 |
| - def resume(workflow_path) |
95 |
| - expanded_workflow_path = if workflow_path.include?("workflow.yml") |
96 |
| - File.expand_path(workflow_path) |
97 |
| - else |
98 |
| - File.expand_path("roast/#{workflow_path}/workflow.yml") |
99 |
| - end |
100 |
| - |
101 |
| - unless File.exist?(expanded_workflow_path) |
102 |
| - raise Thor::Error, "Workflow file not found: #{expanded_workflow_path}" |
103 |
| - end |
104 |
| - |
105 |
| - # Store the event in the session |
106 |
| - repository = Workflow::StateRepositoryFactory.create |
107 |
| - |
108 |
| - unless repository.respond_to?(:add_event) |
109 |
| - raise Thor::Error, "Event resumption requires SQLite storage. Set ROAST_STATE_STORAGE=sqlite" |
110 |
| - end |
111 |
| - |
112 |
| - # Parse event data if provided |
113 |
| - event_data = options[:event_data] ? JSON.parse(options[:event_data]) : nil |
114 |
| - |
115 |
| - # Add the event to the session |
116 |
| - session_id = options[:session_id] |
117 |
| - repository.add_event(expanded_workflow_path, session_id, options[:event], event_data) |
118 |
| - |
119 |
| - # Resume workflow execution from the wait state |
120 |
| - resume_options = options.transform_keys(&:to_sym).merge( |
121 |
| - resume_from_event: options[:event], |
122 |
| - session_id: session_id, |
123 |
| - ) |
124 |
| - |
125 |
| - Roast::Workflow::WorkflowRunner.new(expanded_workflow_path, [], resume_options).begin! |
126 |
| - end |
127 |
| - |
128 |
| - desc "version", "Display the current version of Roast" |
129 |
| - def version |
130 |
| - puts "Roast version #{Roast::VERSION}" |
131 |
| - end |
132 |
| - |
133 |
| - desc "init", "Initialize a new Roast workflow from an example" |
134 |
| - option :example, type: :string, aliases: "-e", desc: "Name of the example to use directly (skips picker)" |
135 |
| - def init |
136 |
| - if options[:example] |
137 |
| - copy_example(options[:example]) |
138 |
| - else |
139 |
| - show_example_picker |
140 |
| - end |
141 |
| - end |
142 |
| - |
143 |
| - desc "list", "List workflows visible to Roast and their source" |
144 |
| - def list |
145 |
| - roast_dir = File.join(Dir.pwd, "roast") |
146 |
| - |
147 |
| - unless File.directory?(roast_dir) |
148 |
| - raise Thor::Error, "No roast/ directory found in current path" |
149 |
| - end |
150 |
| - |
151 |
| - workflow_files = Dir.glob(File.join(roast_dir, "**/workflow.yml")).sort |
152 |
| - |
153 |
| - if workflow_files.empty? |
154 |
| - raise Thor::Error, "No workflow.yml files found in roast/ directory" |
155 |
| - end |
156 |
| - |
157 |
| - puts "Available workflows:" |
158 |
| - puts |
159 |
| - |
160 |
| - workflow_files.each do |file| |
161 |
| - workflow_name = File.dirname(file.sub("#{roast_dir}/", "")) |
162 |
| - puts " #{workflow_name} (from project)" |
163 |
| - end |
164 |
| - |
165 |
| - puts |
166 |
| - puts "Run a workflow with: roast execute <workflow_name>" |
167 |
| - end |
168 |
| - |
169 |
| - desc "validate [WORKFLOW_CONFIGURATION_FILE]", "Validate a workflow configuration" |
170 |
| - option :strict, type: :boolean, aliases: "-s", desc: "Treat warnings as errors" |
171 |
| - def validate(workflow_path = nil) |
172 |
| - validation_command = Roast::Workflow::ValidationCommand.new(options) |
173 |
| - validation_command.execute(workflow_path) |
174 |
| - end |
175 |
| - |
176 |
| - desc "sessions", "List stored workflow sessions" |
177 |
| - option :status, type: :string, aliases: "-s", desc: "Filter by status (running, waiting, completed, failed)" |
178 |
| - option :workflow, type: :string, aliases: "-w", desc: "Filter by workflow name" |
179 |
| - option :older_than, type: :string, desc: "Show sessions older than specified time (e.g., '7d', '1h')" |
180 |
| - option :cleanup, type: :boolean, desc: "Clean up old sessions" |
181 |
| - def sessions |
182 |
| - repository = Workflow::StateRepositoryFactory.create |
183 |
| - |
184 |
| - unless repository.respond_to?(:list_sessions) |
185 |
| - raise Thor::Error, "Session listing is only available with SQLite storage. Set ROAST_STATE_STORAGE=sqlite" |
186 |
| - end |
187 |
| - |
188 |
| - if options[:cleanup] && options[:older_than] |
189 |
| - count = repository.cleanup_old_sessions(options[:older_than]) |
190 |
| - puts "Cleaned up #{count} old sessions" |
191 |
| - return |
192 |
| - end |
193 |
| - |
194 |
| - sessions = repository.list_sessions( |
195 |
| - status: options[:status], |
196 |
| - workflow_name: options[:workflow], |
197 |
| - older_than: options[:older_than], |
198 |
| - ) |
199 |
| - |
200 |
| - if sessions.empty? |
201 |
| - puts "No sessions found" |
202 |
| - return |
203 |
| - end |
204 |
| - |
205 |
| - puts "Found #{sessions.length} session(s):" |
206 |
| - puts |
207 |
| - |
208 |
| - sessions.each do |session| |
209 |
| - id, workflow_name, _, status, current_step, created_at, updated_at = session |
210 |
| - |
211 |
| - puts "Session: #{id}" |
212 |
| - puts " Workflow: #{workflow_name}" |
213 |
| - puts " Status: #{status}" |
214 |
| - puts " Current step: #{current_step || "N/A"}" |
215 |
| - puts " Created: #{created_at}" |
216 |
| - puts " Updated: #{updated_at}" |
217 |
| - puts |
218 |
| - end |
219 |
| - end |
220 |
| - |
221 |
| - desc "session SESSION_ID", "Show details for a specific session" |
222 |
| - def session(session_id) |
223 |
| - repository = Workflow::StateRepositoryFactory.create |
224 |
| - |
225 |
| - unless repository.respond_to?(:get_session_details) |
226 |
| - raise Thor::Error, "Session details are only available with SQLite storage. Set ROAST_STATE_STORAGE=sqlite" |
227 |
| - end |
228 |
| - |
229 |
| - details = repository.get_session_details(session_id) |
230 |
| - |
231 |
| - unless details |
232 |
| - raise Thor::Error, "Session not found: #{session_id}" |
233 |
| - end |
234 |
| - |
235 |
| - session = details[:session] |
236 |
| - states = details[:states] |
237 |
| - events = details[:events] |
238 |
| - |
239 |
| - puts "Session: #{session[0]}" |
240 |
| - puts "Workflow: #{session[1]}" |
241 |
| - puts "Path: #{session[2]}" |
242 |
| - puts "Status: #{session[3]}" |
243 |
| - puts "Created: #{session[6]}" |
244 |
| - puts "Updated: #{session[7]}" |
245 |
| - |
246 |
| - if session[5] |
247 |
| - puts |
248 |
| - puts "Final output:" |
249 |
| - puts session[5] |
250 |
| - end |
251 |
| - |
252 |
| - if states && !states.empty? |
253 |
| - puts |
254 |
| - puts "Steps executed:" |
255 |
| - states.each do |step_index, step_name, created_at| |
256 |
| - puts " #{step_index}: #{step_name} (#{created_at})" |
257 |
| - end |
258 |
| - end |
259 |
| - |
260 |
| - if events && !events.empty? |
261 |
| - puts |
262 |
| - puts "Events:" |
263 |
| - events.each do |event_name, event_data, received_at| |
264 |
| - puts " #{event_name} at #{received_at}" |
265 |
| - puts " Data: #{event_data}" if event_data |
266 |
| - end |
267 |
| - end |
268 |
| - end |
269 |
| - |
270 |
| - desc "diagram WORKFLOW_FILE", "Generate a visual diagram of a workflow" |
271 |
| - option :output, type: :string, aliases: "-o", desc: "Output file path (defaults to workflow_name_diagram.png)" |
272 |
| - def diagram(workflow_file) |
273 |
| - unless File.exist?(workflow_file) |
274 |
| - raise Thor::Error, "Workflow file not found: #{workflow_file}" |
275 |
| - end |
276 |
| - |
277 |
| - workflow = Workflow::Configuration.new(workflow_file) |
278 |
| - generator = WorkflowDiagramGenerator.new(workflow, workflow_file) |
279 |
| - output_path = generator.generate(options[:output]) |
280 |
| - |
281 |
| - puts ::CLI::UI.fmt("{{success:✓}} Diagram generated: #{output_path}") |
282 |
| - rescue StandardError => e |
283 |
| - raise Thor::Error, "Error generating diagram: #{e.message}" |
284 |
| - end |
285 |
| - |
286 |
| - private |
287 |
| - |
288 |
| - def show_example_picker |
289 |
| - examples = available_examples |
290 |
| - |
291 |
| - if examples.empty? |
292 |
| - puts "No examples found!" |
293 |
| - return |
294 |
| - end |
295 |
| - |
296 |
| - puts "Select an option:" |
297 |
| - choices = ["Pick from examples", "New from prompt (beta)"] |
298 |
| - |
299 |
| - selected = run_picker(choices, "Select initialization method:") |
300 |
| - |
301 |
| - case selected |
302 |
| - when "Pick from examples" |
303 |
| - example_choice = run_picker(examples, "Select an example:") |
304 |
| - copy_example(example_choice) if example_choice |
305 |
| - when "New from prompt (beta)" |
306 |
| - create_from_prompt |
307 |
| - end |
308 |
| - end |
309 |
| - |
310 |
| - def available_examples |
311 |
| - examples_dir = File.join(Roast::ROOT, "examples") |
312 |
| - return [] unless File.directory?(examples_dir) |
313 |
| - |
314 |
| - Dir.entries(examples_dir) |
315 |
| - .select { |entry| File.directory?(File.join(examples_dir, entry)) && entry != "." && entry != ".." } |
316 |
| - .sort |
317 |
| - end |
318 |
| - |
319 |
| - def run_picker(options, prompt) |
320 |
| - return if options.empty? |
321 |
| - |
322 |
| - ::CLI::UI::Prompt.ask(prompt) do |handler| |
323 |
| - options.each { |option| handler.option(option) { |selection| selection } } |
324 |
| - end |
325 |
| - end |
326 |
| - |
327 |
| - def copy_example(example_name) |
328 |
| - examples_dir = File.join(Roast::ROOT, "examples") |
329 |
| - source_path = File.join(examples_dir, example_name) |
330 |
| - target_path = File.join(Dir.pwd, example_name) |
331 |
| - |
332 |
| - unless File.directory?(source_path) |
333 |
| - puts "Example '#{example_name}' not found!" |
334 |
| - return |
335 |
| - end |
336 |
| - |
337 |
| - if File.exist?(target_path) |
338 |
| - puts "Directory '#{example_name}' already exists in current directory!" |
339 |
| - return |
340 |
| - end |
341 |
| - |
342 |
| - FileUtils.cp_r(source_path, target_path) |
343 |
| - puts "Successfully copied example '#{example_name}' to current directory." |
344 |
| - end |
345 |
| - |
346 |
| - def create_from_prompt |
347 |
| - puts("Create a new workflow from a description") |
348 |
| - puts |
349 |
| - |
350 |
| - # Execute the workflow generator |
351 |
| - generator_path = File.join(Roast::ROOT, "examples", "workflow_generator", "workflow.yml") |
352 |
| - |
353 |
| - begin |
354 |
| - # Execute the workflow generator (it will handle user input) |
355 |
| - Roast::Workflow::WorkflowRunner.new(generator_path, [], {}).begin! |
356 |
| - |
357 |
| - puts |
358 |
| - puts("Workflow generation complete!") |
359 |
| - rescue => e |
360 |
| - puts("Error generating workflow: #{e.message}") |
361 |
| - end |
362 |
| - end |
363 |
| - |
364 |
| - class << self |
365 |
| - def exit_on_failure? |
366 |
| - true |
367 |
| - end |
368 |
| - end |
369 |
| - end |
370 | 50 | end
|
0 commit comments