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,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 =
|
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
|
@@ -28,11 +28,11 @@ module Roast
|
|
28
28
|
is_last_step = (index == workflow_steps.length - 1)
|
29
29
|
case step
|
30
30
|
when Hash
|
31
|
-
execute(step, is_last_step:
|
31
|
+
execute(step, is_last_step:)
|
32
32
|
when Array
|
33
|
-
execute(step, is_last_step:
|
33
|
+
execute(step, is_last_step:)
|
34
34
|
when String
|
35
|
-
execute(step, is_last_step:
|
35
|
+
execute(step, is_last_step:)
|
36
36
|
# Handle pause after string steps
|
37
37
|
if @context.workflow.pause_step_name == step
|
38
38
|
Kernel.binding.irb # rubocop:disable Lint/Debugger
|
@@ -62,17 +62,17 @@ module Roast
|
|
62
62
|
when StepTypeResolver::AGENT_STEP
|
63
63
|
execute_agent_step(step, options)
|
64
64
|
when StepTypeResolver::GLOB_STEP
|
65
|
-
execute_glob_step(step)
|
65
|
+
execute_glob_step(step, options)
|
66
66
|
when StepTypeResolver::ITERATION_STEP
|
67
|
-
execute_iteration_step(step)
|
67
|
+
execute_iteration_step(step, options)
|
68
68
|
when StepTypeResolver::CONDITIONAL_STEP
|
69
|
-
execute_conditional_step(step)
|
69
|
+
execute_conditional_step(step, options)
|
70
70
|
when StepTypeResolver::CASE_STEP
|
71
|
-
execute_case_step(step)
|
71
|
+
execute_case_step(step, options)
|
72
72
|
when StepTypeResolver::INPUT_STEP
|
73
|
-
execute_input_step(step)
|
73
|
+
execute_input_step(step, options)
|
74
74
|
when StepTypeResolver::HASH_STEP
|
75
|
-
execute_hash_step(step)
|
75
|
+
execute_hash_step(step, options)
|
76
76
|
when StepTypeResolver::PARALLEL_STEP
|
77
77
|
# Use factory for parallel steps
|
78
78
|
executor = StepExecutorFactory.for(step, workflow_executor)
|
@@ -189,11 +189,11 @@ module Roast
|
|
189
189
|
step_orchestrator.execute_step(step_name, exit_on_error:, step_key: options[:step_key], agent_type: :coding_agent)
|
190
190
|
end
|
191
191
|
|
192
|
-
def execute_glob_step(step)
|
192
|
+
def execute_glob_step(step, options = {})
|
193
193
|
Dir.glob(step).join("\n")
|
194
194
|
end
|
195
195
|
|
196
|
-
def execute_iteration_step(step)
|
196
|
+
def execute_iteration_step(step, options = {})
|
197
197
|
name = step.keys.first
|
198
198
|
command = step[name]
|
199
199
|
|
@@ -206,19 +206,19 @@ module Roast
|
|
206
206
|
end
|
207
207
|
end
|
208
208
|
|
209
|
-
def execute_conditional_step(step)
|
209
|
+
def execute_conditional_step(step, options = {})
|
210
210
|
conditional_executor.execute_conditional(step)
|
211
211
|
end
|
212
212
|
|
213
|
-
def execute_case_step(step)
|
213
|
+
def execute_case_step(step, options = {})
|
214
214
|
case_executor.execute_case(step)
|
215
215
|
end
|
216
216
|
|
217
|
-
def execute_input_step(step)
|
217
|
+
def execute_input_step(step, options = {})
|
218
218
|
input_executor.execute_input(step["input"])
|
219
219
|
end
|
220
220
|
|
221
|
-
def execute_hash_step(step)
|
221
|
+
def execute_hash_step(step, options = {})
|
222
222
|
name, command = step.to_a.flatten
|
223
223
|
interpolated_name = interpolator.interpolate(name)
|
224
224
|
|
@@ -230,7 +230,8 @@ module Roast
|
|
230
230
|
|
231
231
|
# Execute the command directly using the appropriate executor
|
232
232
|
# Pass the original key name for configuration lookup
|
233
|
-
|
233
|
+
# Merge options to preserve is_last_step
|
234
|
+
result = execute(interpolated_command, { exit_on_error:, step_key: interpolated_name }.merge(options))
|
234
235
|
context.workflow.output[interpolated_name] = result
|
235
236
|
result
|
236
237
|
end
|
@@ -247,10 +248,10 @@ module Roast
|
|
247
248
|
if StepTypeResolver.command_step?(interpolated_step)
|
248
249
|
# Command step - execute directly, preserving any passed options
|
249
250
|
exit_on_error = options.fetch(:exit_on_error, true)
|
250
|
-
execute_command_step(interpolated_step, { exit_on_error:
|
251
|
+
execute_command_step(interpolated_step, { exit_on_error: })
|
251
252
|
else
|
252
253
|
exit_on_error = options.fetch(:exit_on_error, context.exit_on_error?(step))
|
253
|
-
execute_standard_step(interpolated_step, options.merge(exit_on_error:
|
254
|
+
execute_standard_step(interpolated_step, options.merge(exit_on_error:))
|
254
255
|
end
|
255
256
|
end
|
256
257
|
|
@@ -0,0 +1,68 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
# Decorator that adds token consumption reporting to step execution
|
6
|
+
class StepExecutorWithReporting
|
7
|
+
def initialize(base_executor, context, output: $stderr)
|
8
|
+
@base_executor = base_executor
|
9
|
+
@context = context
|
10
|
+
@reporter = StepCompletionReporter.new(output: output)
|
11
|
+
@name_extractor = StepNameExtractor.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def execute(step, options = {})
|
15
|
+
# Track tokens before execution
|
16
|
+
tokens_before = @context.workflow.context_manager&.total_tokens || 0
|
17
|
+
|
18
|
+
# Execute the step
|
19
|
+
result = @base_executor.execute(step, options)
|
20
|
+
|
21
|
+
# Report token consumption after successful execution
|
22
|
+
tokens_after = @context.workflow.context_manager&.total_tokens || 0
|
23
|
+
tokens_consumed = tokens_after - tokens_before
|
24
|
+
|
25
|
+
step_type = StepTypeResolver.resolve(step, @context)
|
26
|
+
step_name = @name_extractor.extract(step, step_type)
|
27
|
+
@reporter.report(step_name, tokens_consumed, tokens_after)
|
28
|
+
|
29
|
+
result
|
30
|
+
end
|
31
|
+
|
32
|
+
# Override execute_steps to ensure reporting happens for each step
|
33
|
+
def execute_steps(workflow_steps)
|
34
|
+
workflow_steps.each_with_index do |step, index|
|
35
|
+
is_last_step = (index == workflow_steps.length - 1)
|
36
|
+
case step
|
37
|
+
when Hash
|
38
|
+
execute(step, is_last_step:)
|
39
|
+
when Array
|
40
|
+
execute(step, is_last_step:)
|
41
|
+
when String
|
42
|
+
execute(step, is_last_step:)
|
43
|
+
# Handle pause after string steps
|
44
|
+
if @context.workflow.pause_step_name == step
|
45
|
+
Kernel.binding.irb # rubocop:disable Lint/Debugger
|
46
|
+
end
|
47
|
+
else
|
48
|
+
# For other types, delegate to base executor
|
49
|
+
execute(step, is_last_step:)
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
# Delegate all other methods to the base executor
|
55
|
+
def method_missing(method, *args, **kwargs, &block)
|
56
|
+
if @base_executor.respond_to?(method)
|
57
|
+
@base_executor.send(method, *args, **kwargs, &block)
|
58
|
+
else
|
59
|
+
super
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
def respond_to_missing?(method, include_private = false)
|
64
|
+
@base_executor.respond_to?(method, include_private) || super
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|
68
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
# Extracts human-readable names from various step types
|
6
|
+
class StepNameExtractor
|
7
|
+
def extract(step, step_type)
|
8
|
+
case step_type
|
9
|
+
when StepTypeResolver::COMMAND_STEP
|
10
|
+
extract_command_name(step)
|
11
|
+
when StepTypeResolver::HASH_STEP
|
12
|
+
extract_hash_step_name(step)
|
13
|
+
when StepTypeResolver::ITERATION_STEP
|
14
|
+
extract_iteration_step_name(step)
|
15
|
+
when StepTypeResolver::CONDITIONAL_STEP
|
16
|
+
extract_conditional_step_name(step)
|
17
|
+
when StepTypeResolver::CASE_STEP
|
18
|
+
"case"
|
19
|
+
when StepTypeResolver::INPUT_STEP
|
20
|
+
"input"
|
21
|
+
when StepTypeResolver::AGENT_STEP
|
22
|
+
StepTypeResolver.extract_name(step)
|
23
|
+
when StepTypeResolver::STRING_STEP
|
24
|
+
step.to_s
|
25
|
+
else
|
26
|
+
step.to_s
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
private
|
31
|
+
|
32
|
+
def extract_command_name(step)
|
33
|
+
cmd = step.to_s.strip
|
34
|
+
cmd.length > 20 ? "#{cmd[0..19]}..." : cmd
|
35
|
+
end
|
36
|
+
|
37
|
+
def extract_hash_step_name(step)
|
38
|
+
key, value = step.to_a.first
|
39
|
+
|
40
|
+
# Check if this looks like an inline prompt (key is similar to sanitized value)
|
41
|
+
if value.is_a?(String)
|
42
|
+
# Get first non-empty line
|
43
|
+
first_line = value.lines.map(&:strip).find { |line| !line.empty? } || ""
|
44
|
+
|
45
|
+
# If key looks like it was auto-generated from the content, use truncated content
|
46
|
+
sanitized = first_line.downcase.gsub(/[^a-z0-9_]/, "_").squeeze("_").gsub(/^_|_$/, "")
|
47
|
+
if key.to_s == sanitized || key.to_s.start_with?(sanitized[0..15])
|
48
|
+
# This is likely an inline prompt
|
49
|
+
first_line.length > 20 ? "#{first_line[0..19]}..." : first_line
|
50
|
+
else
|
51
|
+
# This is a labeled step
|
52
|
+
key.to_s
|
53
|
+
end
|
54
|
+
else
|
55
|
+
key.to_s
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
def extract_iteration_step_name(step)
|
60
|
+
if step.key?("each")
|
61
|
+
items = step["each"]
|
62
|
+
count = items.respond_to?(:size) ? items.size : "?"
|
63
|
+
"each (#{count} items)"
|
64
|
+
elsif step.key?("repeat")
|
65
|
+
config = step["repeat"]
|
66
|
+
times = config.is_a?(Hash) ? config["times"] || "?" : config
|
67
|
+
"repeat (#{times} times)"
|
68
|
+
else
|
69
|
+
"iteration"
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
def extract_conditional_step_name(step)
|
74
|
+
if step.key?("if")
|
75
|
+
"if"
|
76
|
+
elsif step.key?("unless")
|
77
|
+
"unless"
|
78
|
+
else
|
79
|
+
"conditional"
|
80
|
+
end
|
81
|
+
end
|
82
|
+
end
|
83
|
+
end
|
84
|
+
end
|