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.
- checksums.yaml +4 -4
- data/.github/workflows/ci.yaml +2 -2
- data/.gitignore +1 -0
- data/CHANGELOG.md +103 -0
- data/CLAUDE.md +55 -9
- data/Gemfile.lock +19 -10
- 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/rb_test_runner +1 -1
- 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/errors.rb +3 -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 +65 -10
- 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/command_executor.rb +3 -1
- data/lib/roast/workflow/configuration.rb +2 -1
- data/lib/roast/workflow/configuration_loader.rb +63 -1
- data/lib/roast/workflow/configuration_parser.rb +2 -0
- data/lib/roast/workflow/context_manager.rb +89 -0
- data/lib/roast/workflow/each_step.rb +1 -1
- data/lib/roast/workflow/input_step.rb +2 -0
- data/lib/roast/workflow/interpolator.rb +23 -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_initializer.rb +80 -0
- data/lib/roast/workflow/workflow_runner.rb +6 -0
- data/lib/roast/workflow_diagram_generator.rb +298 -0
- data/lib/roast.rb +158 -0
- data/roast.gemspec +4 -1
- data/schema/workflow.json +77 -1
- 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
|
@@ -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 =
|
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
|
@@ -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 ||
|
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 =
|
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
|