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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ class ContextManager
6
+ attr_reader :total_tokens
7
+
8
+ def initialize(token_counter: nil, threshold_checker: nil)
9
+ @token_counter = token_counter || Services::TokenCountingService.new
10
+ @threshold_checker = threshold_checker || Services::ContextThresholdChecker.new
11
+ @total_tokens = 0
12
+ @message_count = 0
13
+ @config = default_config
14
+ @last_actual_update = nil
15
+ @estimated_tokens_since_update = 0
16
+ end
17
+
18
+ def configure(config)
19
+ @config = default_config.merge(config)
20
+ end
21
+
22
+ def track_usage(messages)
23
+ current_tokens = @token_counter.count_messages(messages)
24
+ @total_tokens += current_tokens
25
+ @message_count += messages.size
26
+
27
+ {
28
+ current_tokens: current_tokens,
29
+ total_tokens: @total_tokens,
30
+ }
31
+ end
32
+
33
+ def should_compact?(token_count = @total_tokens)
34
+ return false unless @config[:enabled]
35
+
36
+ @threshold_checker.should_compact?(
37
+ token_count,
38
+ @config[:threshold],
39
+ @config[:max_tokens],
40
+ )
41
+ end
42
+
43
+ def check_warnings(token_count = @total_tokens)
44
+ return unless @config[:enabled]
45
+
46
+ warning = @threshold_checker.check_warning_threshold(
47
+ token_count,
48
+ @config[:threshold],
49
+ @config[:max_tokens],
50
+ )
51
+
52
+ if warning
53
+ ActiveSupport::Notifications.instrument("roast.context_warning", warning)
54
+ end
55
+ end
56
+
57
+ def reset
58
+ @total_tokens = 0
59
+ @message_count = 0
60
+ end
61
+
62
+ def statistics
63
+ {
64
+ total_tokens: @total_tokens,
65
+ message_count: @message_count,
66
+ average_tokens_per_message: @message_count > 0 ? @total_tokens / @message_count : 0,
67
+ }
68
+ end
69
+
70
+ def update_with_actual_usage(actual_total)
71
+ return unless actual_total && actual_total > 0
72
+
73
+ @total_tokens = actual_total
74
+ @last_actual_update = Time.now
75
+ @estimated_tokens_since_update = 0
76
+ end
77
+
78
+ private
79
+
80
+ def default_config
81
+ {
82
+ enabled: true,
83
+ threshold: 0.8,
84
+ max_tokens: nil, # Will use default from threshold checker
85
+ }
86
+ end
87
+ end
88
+ end
89
+ end
@@ -65,7 +65,7 @@ module Roast
65
65
  end
66
66
 
67
67
  def save_iteration_state(index, item)
68
- state_repository = FileStateRepository.new
68
+ state_repository = StateRepositoryFactory.create(workflow.storage_type)
69
69
 
70
70
  # Save the current iteration state
71
71
  state_data = {
@@ -29,6 +29,8 @@ module Roast
29
29
  store_in_state(result) if step_name
30
30
 
31
31
  result
32
+ rescue Interrupt
33
+ raise Roast::Errors::ExitEarly
32
34
  rescue Timeout::Error
33
35
  handle_timeout
34
36
  end
@@ -11,12 +11,22 @@ module Roast
11
11
  def interpolate(text)
12
12
  return text unless text.is_a?(String) && text.include?("{{") && text.include?("}}")
13
13
 
14
+ # Check if this is a shell command context
15
+ is_shell_command = text.strip.start_with?("$(") && text.strip.end_with?(")")
16
+
14
17
  # Replace all {{expression}} with their evaluated values
15
18
  text.gsub(/\{\{([^}]+)\}\}/) do |match|
16
19
  expression = Regexp.last_match(1).strip
17
20
  begin
18
21
  # Evaluate the expression in the context
19
- @context.instance_eval(expression).to_s
22
+ result = @context.instance_eval(expression).to_s
23
+
24
+ # Escape shell metacharacters if this is a shell command
25
+ if is_shell_command
26
+ escape_shell_metacharacters(result)
27
+ else
28
+ result
29
+ end
20
30
  rescue => e
21
31
  # Provide a detailed error message but preserve the original expression
22
32
  error_msg = "Error interpolating {{#{expression}}}: #{e.message}. This variable is not defined in the workflow context."
@@ -26,6 +36,18 @@ module Roast
26
36
  end
27
37
  end
28
38
 
39
+ private
40
+
41
+ # Escape shell metacharacters to prevent injection and command substitution
42
+ # Order matters: escape backslashes first to avoid double-escaping
43
+ def escape_shell_metacharacters(text)
44
+ text
45
+ .gsub("\\", "\\\\\\\\") # Escape backslashes first (4 backslashes become 2, then 1)
46
+ .gsub('"', '\\\\"') # Escape double quotes
47
+ .gsub("$", "\\\\$") # Escape dollar signs (variable expansion)
48
+ .gsub("`", "\\\\`") # Escape backticks (command substitution)
49
+ end
50
+
29
51
  class NullLogger
30
52
  def error(_message); end
31
53
  end
@@ -11,7 +11,7 @@ module Roast
11
11
  final_output = workflow.final_output.to_s
12
12
  return if final_output.empty?
13
13
 
14
- state_repository = FileStateRepository.new
14
+ state_repository = StateRepositoryFactory.create(workflow.storage_type)
15
15
  output_file = state_repository.save_final_output(workflow, final_output)
16
16
  $stderr.puts "Final output saved to: #{output_file}" if output_file
17
17
  rescue => e
@@ -55,7 +55,7 @@ module Roast
55
55
  private
56
56
 
57
57
  def save_iteration_state(iteration)
58
- state_repository = FileStateRepository.new
58
+ state_repository = StateRepositoryFactory.create(workflow.storage_type)
59
59
 
60
60
  # Save the current iteration count in the state
61
61
  state_data = {
@@ -9,7 +9,7 @@ module Roast
9
9
 
10
10
  def initialize(workflow, state_repository: nil)
11
11
  @workflow = workflow
12
- @state_repository = state_repository || FileStateRepository.new
12
+ @state_repository = state_repository || StateRepositoryFactory.create(workflow.storage_type)
13
13
  @processed = false
14
14
  end
15
15
 
@@ -0,0 +1,342 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Roast
6
+ module Workflow
7
+ # SQLite-based implementation of StateRepository
8
+ # Provides structured, queryable session storage with better performance
9
+ class SqliteStateRepository < StateRepository
10
+ DEFAULT_DB_PATH = File.expand_path("~/.roast/sessions.db")
11
+
12
+ def initialize(db_path: nil, session_manager: SessionManager.new)
13
+ super()
14
+
15
+ # Lazy load sqlite3 only when actually using SQLite storage
16
+ begin
17
+ require "sqlite3"
18
+ rescue LoadError
19
+ raise LoadError, "SQLite storage requires the 'sqlite3' gem. Please add it to your Gemfile or install it: gem install sqlite3"
20
+ end
21
+
22
+ @db_path = db_path || ENV["ROAST_SESSIONS_DB"] || DEFAULT_DB_PATH
23
+ @session_manager = session_manager
24
+ ensure_database
25
+ end
26
+
27
+ def save_state(workflow, step_name, state_data)
28
+ workflow.session_timestamp ||= @session_manager.create_new_session(workflow.object_id)
29
+
30
+ session_id = ensure_session(workflow)
31
+
32
+ @db.execute(<<~SQL, [session_id, state_data[:order], step_name, state_data.to_json])
33
+ INSERT INTO session_states (session_id, step_index, step_name, state_data)
34
+ VALUES (?, ?, ?, ?)
35
+ SQL
36
+
37
+ # Update session's current step
38
+ @db.execute(<<~SQL, [state_data[:order], session_id])
39
+ UPDATE sessions#{" "}
40
+ SET current_step_index = ?, updated_at = CURRENT_TIMESTAMP
41
+ WHERE id = ?
42
+ SQL
43
+ rescue => e
44
+ $stderr.puts "Failed to save state for step #{step_name}: #{e.message}"
45
+ end
46
+
47
+ def load_state_before_step(workflow, step_name, timestamp: nil)
48
+ session_id = find_session_id(workflow, timestamp)
49
+ return false unless session_id
50
+
51
+ # Find the state before the target step
52
+ result = @db.execute(<<~SQL, [session_id, step_name])
53
+ SELECT state_data, step_name
54
+ FROM session_states
55
+ WHERE session_id = ?
56
+ AND step_index < (
57
+ SELECT MIN(step_index)#{" "}
58
+ FROM session_states#{" "}
59
+ WHERE session_id = ? AND step_name = ?
60
+ )
61
+ ORDER BY step_index DESC
62
+ LIMIT 1
63
+ SQL
64
+
65
+ if result.empty?
66
+ # Try to find the latest state if target step doesn't exist
67
+ result = @db.execute(<<~SQL, [session_id])
68
+ SELECT state_data, step_name
69
+ FROM session_states
70
+ WHERE session_id = ?
71
+ ORDER BY step_index DESC
72
+ LIMIT 1
73
+ SQL
74
+
75
+ if result.empty?
76
+ $stderr.puts "No state found for session"
77
+ return false
78
+ end
79
+ end
80
+
81
+ state_data = JSON.parse(result[0][0], symbolize_names: true)
82
+ loaded_step = result[0][1]
83
+ $stderr.puts "Found state from step: #{loaded_step} (will replay from here to #{step_name})"
84
+
85
+ # If no timestamp provided and workflow has no session, create new session and copy states
86
+ if !timestamp && workflow.session_timestamp.nil?
87
+ copy_states_to_new_session(workflow, session_id, step_name)
88
+ end
89
+
90
+ state_data
91
+ end
92
+
93
+ def save_final_output(workflow, output_content)
94
+ return if output_content.empty?
95
+
96
+ session_id = ensure_session(workflow)
97
+
98
+ @db.execute(<<~SQL, [output_content, session_id])
99
+ UPDATE sessions#{" "}
100
+ SET final_output = ?, status = 'completed', updated_at = CURRENT_TIMESTAMP
101
+ WHERE id = ?
102
+ SQL
103
+
104
+ session_id
105
+ rescue => e
106
+ $stderr.puts "Failed to save final output: #{e.message}"
107
+ nil
108
+ end
109
+
110
+ # Additional query methods for the new capabilities
111
+
112
+ def list_sessions(status: nil, workflow_name: nil, older_than: nil, limit: 100)
113
+ conditions = []
114
+ params = []
115
+
116
+ if status
117
+ conditions << "status = ?"
118
+ params << status
119
+ end
120
+
121
+ if workflow_name
122
+ conditions << "workflow_name = ?"
123
+ params << workflow_name
124
+ end
125
+
126
+ if older_than
127
+ conditions << "created_at < datetime('now', ?)"
128
+ params << "-#{older_than}"
129
+ end
130
+
131
+ where_clause = conditions.empty? ? "" : "WHERE #{conditions.join(" AND ")}"
132
+
133
+ @db.execute(<<~SQL, params)
134
+ SELECT id, workflow_name, workflow_path, status, current_step_index,#{" "}
135
+ created_at, updated_at
136
+ FROM sessions
137
+ #{where_clause}
138
+ ORDER BY created_at DESC
139
+ LIMIT #{limit}
140
+ SQL
141
+ end
142
+
143
+ def get_session_details(session_id)
144
+ session = @db.execute(<<~SQL, [session_id]).first
145
+ SELECT * FROM sessions WHERE id = ?
146
+ SQL
147
+
148
+ return unless session
149
+
150
+ states = @db.execute(<<~SQL, [session_id])
151
+ SELECT step_index, step_name, created_at
152
+ FROM session_states
153
+ WHERE session_id = ?
154
+ ORDER BY step_index
155
+ SQL
156
+
157
+ events = @db.execute(<<~SQL, [session_id])
158
+ SELECT event_name, event_data, received_at
159
+ FROM session_events
160
+ WHERE session_id = ?
161
+ ORDER BY received_at
162
+ SQL
163
+
164
+ {
165
+ session: session,
166
+ states: states,
167
+ events: events,
168
+ }
169
+ end
170
+
171
+ def cleanup_old_sessions(older_than)
172
+ count = @db.changes
173
+ @db.execute(<<~SQL, ["-#{older_than}"])
174
+ DELETE FROM sessions
175
+ WHERE created_at < datetime('now', ?)
176
+ SQL
177
+ @db.changes - count
178
+ end
179
+
180
+ def add_event(workflow_path, session_id, event_name, event_data = nil)
181
+ # Find the session if session_id not provided
182
+ unless session_id
183
+ workflow_name = File.basename(File.dirname(workflow_path))
184
+ result = @db.execute(<<~SQL, [workflow_name, "waiting"])
185
+ SELECT id FROM sessions
186
+ WHERE workflow_name = ? AND status = ?
187
+ ORDER BY created_at DESC
188
+ LIMIT 1
189
+ SQL
190
+
191
+ raise "No waiting session found for workflow: #{workflow_name}" if result.empty?
192
+
193
+ session_id = result[0][0]
194
+ end
195
+
196
+ # Add the event
197
+ @db.execute(<<~SQL, [session_id, event_name, event_data&.to_json])
198
+ INSERT INTO session_events (session_id, event_name, event_data)
199
+ VALUES (?, ?, ?)
200
+ SQL
201
+
202
+ # Update session status
203
+ @db.execute(<<~SQL, [session_id])
204
+ UPDATE sessions#{" "}
205
+ SET status = 'running', updated_at = CURRENT_TIMESTAMP
206
+ WHERE id = ?
207
+ SQL
208
+
209
+ session_id
210
+ end
211
+
212
+ private
213
+
214
+ def ensure_database
215
+ FileUtils.mkdir_p(File.dirname(@db_path))
216
+ @db = SQLite3::Database.new(@db_path)
217
+ @db.execute("PRAGMA foreign_keys = ON")
218
+ create_schema
219
+ end
220
+
221
+ def create_schema
222
+ @db.execute_batch(<<~SQL)
223
+ CREATE TABLE IF NOT EXISTS sessions (
224
+ id TEXT PRIMARY KEY,
225
+ workflow_name TEXT NOT NULL,
226
+ workflow_path TEXT NOT NULL,
227
+ status TEXT NOT NULL DEFAULT 'running',
228
+ current_step_index INTEGER,
229
+ final_output TEXT,
230
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
231
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
232
+ );
233
+
234
+ CREATE TABLE IF NOT EXISTS session_states (
235
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
236
+ session_id TEXT NOT NULL,
237
+ step_index INTEGER NOT NULL,
238
+ step_name TEXT NOT NULL,
239
+ state_data TEXT NOT NULL,
240
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
241
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
242
+ );
243
+
244
+ CREATE TABLE IF NOT EXISTS session_events (
245
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
246
+ session_id TEXT NOT NULL,
247
+ event_name TEXT NOT NULL,
248
+ event_data TEXT,
249
+ received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
250
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
251
+ );
252
+
253
+ CREATE TABLE IF NOT EXISTS session_variables (
254
+ session_id TEXT NOT NULL,
255
+ key TEXT NOT NULL,
256
+ value TEXT NOT NULL,
257
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
258
+ PRIMARY KEY (session_id, key),
259
+ FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE
260
+ );
261
+
262
+ -- Indexes for common queries
263
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
264
+ CREATE INDEX IF NOT EXISTS idx_sessions_workflow_name ON sessions(workflow_name);
265
+ CREATE INDEX IF NOT EXISTS idx_sessions_created_at ON sessions(created_at);
266
+ CREATE INDEX IF NOT EXISTS idx_session_states_session_id ON session_states(session_id);
267
+ CREATE INDEX IF NOT EXISTS idx_session_events_session_id ON session_events(session_id);
268
+ SQL
269
+ end
270
+
271
+ def ensure_session(workflow)
272
+ session_id = generate_session_id(workflow)
273
+
274
+ # Check if session exists
275
+ existing = @db.execute("SELECT id FROM sessions WHERE id = ?", [session_id]).first
276
+ return session_id if existing
277
+
278
+ # Create new session
279
+ workflow_name = workflow.session_name || "unnamed"
280
+ workflow_path = workflow.file || "notarget"
281
+
282
+ @db.execute(<<~SQL, [session_id, workflow_name, workflow_path])
283
+ INSERT INTO sessions (id, workflow_name, workflow_path)
284
+ VALUES (?, ?, ?)
285
+ SQL
286
+
287
+ session_id
288
+ end
289
+
290
+ def find_session_id(workflow, timestamp)
291
+ if timestamp
292
+ # Find by exact timestamp
293
+ generate_session_id(workflow, timestamp)
294
+ else
295
+ # Find latest session for this workflow
296
+ workflow_name = workflow.session_name || "unnamed"
297
+ workflow_path = workflow.file || "notarget"
298
+
299
+ result = @db.execute(<<~SQL, [workflow_name, workflow_path])
300
+ SELECT id FROM sessions
301
+ WHERE workflow_name = ? AND workflow_path = ?
302
+ ORDER BY created_at DESC
303
+ LIMIT 1
304
+ SQL
305
+
306
+ result.empty? ? nil : result[0][0]
307
+ end
308
+ end
309
+
310
+ def generate_session_id(workflow, timestamp = nil)
311
+ timestamp ||= workflow.session_timestamp || @session_manager.create_new_session(workflow.object_id)
312
+ workflow_name = workflow.session_name || "unnamed"
313
+ workflow_path = workflow.file || "notarget"
314
+
315
+ # Generate a unique session ID based on workflow info and timestamp
316
+ file_hash = Digest::MD5.hexdigest(workflow_path)[0..7]
317
+ "#{workflow_name.parameterize.underscore}_#{file_hash}_#{timestamp}"
318
+ end
319
+
320
+ def copy_states_to_new_session(workflow, source_session_id, target_step_name)
321
+ # Create new session
322
+ new_timestamp = @session_manager.create_new_session(workflow.object_id)
323
+ workflow.session_timestamp = new_timestamp
324
+ new_session_id = ensure_session(workflow)
325
+
326
+ # Copy states up to the target step
327
+ @db.execute(<<~SQL, [new_session_id, source_session_id, target_step_name, source_session_id])
328
+ INSERT INTO session_states (session_id, step_index, step_name, state_data)
329
+ SELECT ?, step_index, step_name, state_data
330
+ FROM session_states
331
+ WHERE session_id = ?
332
+ AND step_index < COALESCE(
333
+ (SELECT MIN(step_index) FROM session_states WHERE session_id = ? AND step_name = ?),
334
+ 999999
335
+ )
336
+ SQL
337
+
338
+ true
339
+ end
340
+ end
341
+ end
342
+ end
@@ -6,10 +6,10 @@ module Roast
6
6
  class StateManager
7
7
  attr_reader :workflow, :logger
8
8
 
9
- def initialize(workflow, logger: nil)
9
+ def initialize(workflow, logger: nil, state_repository: nil, storage_type: nil)
10
10
  @workflow = workflow
11
11
  @logger = logger
12
- @state_repository = FileStateRepository.new
12
+ @state_repository = state_repository || StateRepositoryFactory.create(storage_type)
13
13
  end
14
14
 
15
15
  # Save the current state after a step execution
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Factory for creating the appropriate StateRepository implementation
6
+ class StateRepositoryFactory
7
+ class << self
8
+ def create(type = nil)
9
+ type ||= default_type
10
+
11
+ case type.to_s
12
+ when "sqlite"
13
+ # Lazy load the SQLite repository only when needed
14
+ Roast::Workflow::SqliteStateRepository.new
15
+ when "file", "filesystem"
16
+ Roast::Workflow::FileStateRepository.new
17
+ else
18
+ raise ArgumentError, "Unknown state repository type: #{type}. Valid types are: sqlite, file"
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def default_type
25
+ # Check environment variable first (for backwards compatibility)
26
+ if ENV["ROAST_STATE_STORAGE"]
27
+ ENV["ROAST_STATE_STORAGE"].downcase
28
+ else
29
+ # Default to SQLite for better functionality
30
+ "sqlite"
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roast
4
+ module Workflow
5
+ # Reports step completion with token consumption information
6
+ class StepCompletionReporter
7
+ def initialize(output: $stderr)
8
+ @output = output
9
+ end
10
+
11
+ def report(step_name, tokens_consumed, total_tokens)
12
+ formatted_consumed = number_with_delimiter(tokens_consumed)
13
+ formatted_total = number_with_delimiter(total_tokens)
14
+
15
+ @output.puts "✓ Complete: #{step_name} (consumed #{formatted_consumed} tokens, total #{formatted_total})"
16
+ @output.puts
17
+ @output.puts
18
+ end
19
+
20
+ private
21
+
22
+ def number_with_delimiter(number)
23
+ number.to_s.gsub(/(\d)(?=(\d{3})+(?!\d))/, '\\1,')
24
+ end
25
+ end
26
+ end
27
+ end