roast-ai 0.4.1 → 0.4.3
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/.gitignore +1 -0
- data/CHANGELOG.md +43 -0
- data/Gemfile +0 -1
- data/Gemfile.lock +48 -23
- data/README.md +228 -29
- data/examples/coding_agent_with_model.yml +20 -0
- data/examples/coding_agent_with_retries.yml +30 -0
- data/examples/grading/rb_test_runner +1 -1
- data/lib/roast/errors.rb +3 -0
- data/lib/roast/helpers/metadata_access.rb +39 -0
- data/lib/roast/helpers/timeout_handler.rb +1 -1
- data/lib/roast/tools/coding_agent.rb +99 -27
- data/lib/roast/tools/grep.rb +4 -0
- data/lib/roast/version.rb +1 -1
- data/lib/roast/workflow/agent_step.rb +57 -4
- data/lib/roast/workflow/base_workflow.rb +4 -2
- data/lib/roast/workflow/command_executor.rb +3 -1
- data/lib/roast/workflow/configuration_parser.rb +2 -0
- data/lib/roast/workflow/each_step.rb +5 -3
- data/lib/roast/workflow/input_step.rb +2 -0
- data/lib/roast/workflow/interpolator.rb +23 -1
- data/lib/roast/workflow/metadata_manager.rb +47 -0
- data/lib/roast/workflow/output_handler.rb +1 -0
- data/lib/roast/workflow/replay_handler.rb +8 -0
- data/lib/roast/workflow/shell_script_step.rb +115 -0
- data/lib/roast/workflow/sqlite_state_repository.rb +17 -17
- data/lib/roast/workflow/state_manager.rb +8 -0
- data/lib/roast/workflow/step_executor_coordinator.rb +43 -8
- data/lib/roast/workflow/step_executor_with_reporting.rb +2 -2
- data/lib/roast/workflow/step_loader.rb +55 -9
- data/lib/roast/workflow/workflow_executor.rb +3 -4
- data/lib/roast/workflow/workflow_initializer.rb +95 -4
- data/lib/roast/workflow/workflow_runner.rb +2 -2
- data/lib/roast.rb +2 -0
- data/roast.gemspec +3 -2
- metadata +36 -18
- data/lib/roast/workflow/step_orchestrator.rb +0 -48
@@ -4,6 +4,7 @@ module Roast
|
|
4
4
|
module Tools
|
5
5
|
module CodingAgent
|
6
6
|
extend self
|
7
|
+
include Roast::Helpers::MetadataAccess
|
7
8
|
|
8
9
|
class CodingAgentError < StandardError; end
|
9
10
|
|
@@ -11,9 +12,10 @@ module Roast
|
|
11
12
|
private_constant :CONFIG_CODING_AGENT_COMMAND
|
12
13
|
|
13
14
|
@configured_command = nil
|
15
|
+
@configured_options = {}
|
14
16
|
|
15
17
|
class << self
|
16
|
-
attr_accessor :configured_command
|
18
|
+
attr_accessor :configured_command, :configured_options
|
17
19
|
|
18
20
|
def included(base)
|
19
21
|
base.class_eval do
|
@@ -23,11 +25,13 @@ module Roast
|
|
23
25
|
prompt: { type: "string", description: "The prompt to send to Claude Code" },
|
24
26
|
include_context_summary: { type: "boolean", description: "Whether to set a summary of the current workflow context as system directive (default: false)", required: false },
|
25
27
|
continue: { type: "boolean", description: "Whether to continue where the previous coding agent left off or start with a fresh context (default: false, start fresh)", required: false },
|
28
|
+
retries: { type: "integer", description: "Number of times to retry the coding agent invocation if it terminates with an error (default: 0, no retry)", required: false },
|
26
29
|
) do |params|
|
27
30
|
Roast::Tools::CodingAgent.call(
|
28
31
|
params[:prompt],
|
29
32
|
include_context_summary: params[:include_context_summary].presence || false,
|
30
33
|
continue: params[:continue].presence || false,
|
34
|
+
retries: params[:retries],
|
31
35
|
)
|
32
36
|
end
|
33
37
|
end
|
@@ -36,14 +40,26 @@ module Roast
|
|
36
40
|
# Called after configuration is loaded
|
37
41
|
def post_configuration_setup(base, config = {})
|
38
42
|
self.configured_command = config[CONFIG_CODING_AGENT_COMMAND]
|
43
|
+
# Store any other configuration options (like model)
|
44
|
+
self.configured_options = config.except(CONFIG_CODING_AGENT_COMMAND)
|
39
45
|
end
|
40
46
|
end
|
41
47
|
|
42
|
-
def call(prompt, include_context_summary: false, continue: false)
|
43
|
-
|
44
|
-
|
48
|
+
def call(prompt, include_context_summary: false, continue: false, retries: nil)
|
49
|
+
# Use configured retries as default, fall back to 0 if not configured
|
50
|
+
retries ||= CodingAgent.configured_options[:retries] || CodingAgent.configured_options["retries"] || 0
|
51
|
+
(retries + 1).times do |iteration|
|
52
|
+
Roast::Helpers::Logger.info("🤖 Running CodingAgent#{iteration > 0 ? ", attempt #{iteration + 1} of #{retries + 1}" : ""}\n")
|
53
|
+
return run_claude_code(prompt, include_context_summary:, continue:)
|
54
|
+
rescue CodingAgentError => e
|
55
|
+
raise e if iteration >= retries
|
56
|
+
|
57
|
+
Roast::Helpers::Logger.warn("🤖 Retrying after error running CodingAgent: #{e.message}")
|
58
|
+
Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
|
59
|
+
end
|
60
|
+
Roast::Helpers::Logger.error("🤖 CodingAgent did not complete successfully after multiple retries")
|
45
61
|
rescue StandardError => e
|
46
|
-
"Error running CodingAgent: #{e.message}".tap do |error_message|
|
62
|
+
"🤖 Error running CodingAgent: #{e.message}".tap do |error_message|
|
47
63
|
Roast::Helpers::Logger.error(error_message + "\n")
|
48
64
|
Roast::Helpers::Logger.debug(e.backtrace.join("\n") + "\n") if ENV["DEBUG"]
|
49
65
|
end
|
@@ -68,9 +84,19 @@ module Roast
|
|
68
84
|
temp_file.write(final_prompt)
|
69
85
|
temp_file.close
|
70
86
|
|
71
|
-
#
|
87
|
+
# Check for session ID if continue is requested
|
88
|
+
# Resuming from a specific session id is more resilient than simply continuing if there are
|
89
|
+
# parallel invocations of claude being run in the same working directory.
|
90
|
+
session_id = nil
|
91
|
+
if continue
|
92
|
+
session_id = step_metadata["coding_agent_session_id"]
|
93
|
+
end
|
94
|
+
|
95
|
+
# Build the command with continue option (may become resume if session_id exists)
|
72
96
|
base_command = claude_code_command
|
73
|
-
command_to_run = build_command(base_command, continue:)
|
97
|
+
command_to_run = build_command(base_command, continue:, session_id:)
|
98
|
+
|
99
|
+
Roast::Helpers::Logger.debug(command_to_run)
|
74
100
|
|
75
101
|
# Run Claude Code CLI using the temp file as input with streaming output
|
76
102
|
expect_json_output = command_to_run.include?("--output-format stream-json") ||
|
@@ -85,19 +111,22 @@ module Roast
|
|
85
111
|
json = parse_json(line)
|
86
112
|
next unless json
|
87
113
|
|
114
|
+
handle_session_info(json)
|
88
115
|
handle_intermediate_message(json)
|
89
|
-
|
116
|
+
handled_result = handle_result(json)
|
117
|
+
result += handled_result if handled_result
|
90
118
|
end
|
91
119
|
else
|
92
120
|
result = stdout.read
|
121
|
+
# Clear any stale session ID we might have when not using JSON formatting
|
122
|
+
set_current_step_metadata("coding_agent_session_id", nil)
|
93
123
|
end
|
94
124
|
|
95
125
|
status = wait_thread.value
|
96
126
|
if status.success?
|
97
127
|
return result
|
98
128
|
else
|
99
|
-
|
100
|
-
return "Error running CodingAgent: #{error_output}"
|
129
|
+
raise CodingAgentError, stderr.read
|
101
130
|
end
|
102
131
|
end
|
103
132
|
ensure
|
@@ -127,13 +156,18 @@ module Roast
|
|
127
156
|
def handle_result(json)
|
128
157
|
if json["type"] == "result"
|
129
158
|
# NOTE: the format of an error response is { "subtype": "success", "is_error": true }
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
134
|
-
|
135
|
-
|
136
|
-
|
159
|
+
is_error = json["is_error"] || false
|
160
|
+
success = !is_error && json["subtype"] == "success"
|
161
|
+
raise CodingAgentError, json.inspect unless success
|
162
|
+
|
163
|
+
json["result"]
|
164
|
+
end
|
165
|
+
end
|
166
|
+
|
167
|
+
def handle_session_info(json)
|
168
|
+
session_id = json["session_id"]
|
169
|
+
if session_id
|
170
|
+
set_current_step_metadata("coding_agent_session_id", session_id)
|
137
171
|
end
|
138
172
|
end
|
139
173
|
|
@@ -147,20 +181,58 @@ module Roast
|
|
147
181
|
end
|
148
182
|
|
149
183
|
def claude_code_command
|
150
|
-
CodingAgent.configured_command || ENV["CLAUDE_CODE_COMMAND"] || "claude -p --verbose --output-format stream-json"
|
184
|
+
CodingAgent.configured_command || ENV["CLAUDE_CODE_COMMAND"] || "claude -p --verbose --output-format stream-json --dangerously-skip-permissions"
|
151
185
|
end
|
152
186
|
|
153
|
-
def build_command(base_command, continue:)
|
154
|
-
|
187
|
+
def build_command(base_command, continue:, session_id: nil)
|
188
|
+
command = base_command.dup
|
155
189
|
|
156
|
-
# Add --
|
157
|
-
|
158
|
-
if
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
|
190
|
+
# Add configured options (like --model), excluding retries which is handled internally
|
191
|
+
options_for_command = CodingAgent.configured_options.except("retries", :retries)
|
192
|
+
if options_for_command.any?
|
193
|
+
options_str = build_options_string(options_for_command)
|
194
|
+
command = if command.start_with?("claude ")
|
195
|
+
command.sub("claude ", "claude #{options_str} ")
|
196
|
+
else
|
197
|
+
# For non-standard commands, append at the end
|
198
|
+
"#{command} #{options_str}"
|
199
|
+
end
|
163
200
|
end
|
201
|
+
|
202
|
+
# Add --resume or --continue flag based on continue option and session_id value
|
203
|
+
if continue
|
204
|
+
command = if session_id
|
205
|
+
# Use --resume with session ID if available
|
206
|
+
if command.start_with?("claude ")
|
207
|
+
command.sub("claude ", "claude --resume #{session_id} ")
|
208
|
+
else
|
209
|
+
# Fallback for non-standard commands
|
210
|
+
"#{command} --resume #{session_id}"
|
211
|
+
end
|
212
|
+
elsif command.start_with?("claude ")
|
213
|
+
# Use --continue if no session ID
|
214
|
+
command.sub("claude ", "claude --continue ")
|
215
|
+
else
|
216
|
+
# Fallback for non-standard commands
|
217
|
+
"#{command} --continue"
|
218
|
+
end
|
219
|
+
end
|
220
|
+
|
221
|
+
command
|
222
|
+
end
|
223
|
+
|
224
|
+
def build_options_string(options)
|
225
|
+
options.map do |key, value|
|
226
|
+
# Convert Ruby hash keys to command line format
|
227
|
+
flag = "--#{key.to_s.tr("_", "-")}"
|
228
|
+
if value == true
|
229
|
+
flag
|
230
|
+
elsif value == false || value.nil?
|
231
|
+
nil
|
232
|
+
else
|
233
|
+
"#{flag} #{value}"
|
234
|
+
end
|
235
|
+
end.compact.join(" ")
|
164
236
|
end
|
165
237
|
|
166
238
|
def prepare_prompt(prompt, include_context_summary)
|
data/lib/roast/tools/grep.rb
CHANGED
@@ -27,6 +27,10 @@ module Roast
|
|
27
27
|
def call(string)
|
28
28
|
Roast::Helpers::Logger.info("🔍 Grepping for string: #{string}\n")
|
29
29
|
|
30
|
+
unless system("command -v rg >/dev/null 2>&1")
|
31
|
+
raise "ripgrep is not available. Please install it using your package manager (e.g., brew install rg) and make sure it's on your PATH."
|
32
|
+
end
|
33
|
+
|
30
34
|
# Use Open3 to safely pass the string as an argument, avoiding shell injection
|
31
35
|
cmd = ["rg", "-C", "4", "--trim", "--color=never", "--heading", "-F", "--", string, "."]
|
32
36
|
stdout, _stderr, _status = Open3.capture3(*cmd)
|
data/lib/roast/version.rb
CHANGED
@@ -3,6 +3,15 @@
|
|
3
3
|
module Roast
|
4
4
|
module Workflow
|
5
5
|
class AgentStep < BaseStep
|
6
|
+
attr_accessor :include_context_summary, :continue, :resume
|
7
|
+
|
8
|
+
def initialize(workflow, **kwargs)
|
9
|
+
super
|
10
|
+
# Set default values for agent-specific options
|
11
|
+
@include_context_summary = false
|
12
|
+
@continue = false
|
13
|
+
end
|
14
|
+
|
6
15
|
def call
|
7
16
|
# For inline prompts (detected by plain text step names), use the name as the prompt
|
8
17
|
# For file-based steps, load from the prompt file
|
@@ -12,22 +21,66 @@ module Roast
|
|
12
21
|
read_sidecar_prompt
|
13
22
|
end
|
14
23
|
|
15
|
-
#
|
16
|
-
|
24
|
+
# Handle resume option by copying session ID from referenced step
|
25
|
+
if @resume.present?
|
26
|
+
if (session_id = workflow.metadata.dig(@resume, "coding_agent_session_id"))
|
27
|
+
workflow.metadata[name.to_s] ||= {}
|
28
|
+
workflow.metadata[name.to_s]["coding_agent_session_id"] = session_id
|
29
|
+
else
|
30
|
+
Roast::Helpers::Logger.warn("Cannot resume from step '#{@resume}'. It does not have a coding_agent_session_id in its metadata.")
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
# Use agent-specific configuration that was applied by StepLoader
|
35
|
+
# If resume is set and a session_id is available, translate it to continue
|
17
36
|
agent_options = {
|
18
|
-
include_context_summary:
|
19
|
-
continue:
|
37
|
+
include_context_summary: @include_context_summary,
|
38
|
+
continue: @continue || session_id.present?, # Use continue if either continue is set or a session_id to resume from is available
|
20
39
|
}
|
21
40
|
|
22
41
|
# Call CodingAgent directly with the prompt content and options
|
23
42
|
result = Roast::Tools::CodingAgent.call(prompt_content, **agent_options)
|
24
43
|
|
44
|
+
# Parse as JSON if json: true is configured (since CodingAgent response is not handled by Raix)
|
45
|
+
if @json && result.is_a?(String)
|
46
|
+
# Don't try to parse error messages as JSON
|
47
|
+
if result.start_with?("Error running CodingAgent:")
|
48
|
+
raise result
|
49
|
+
end
|
50
|
+
|
51
|
+
# Extract JSON from markdown code blocks anywhere in the response
|
52
|
+
cleaned_result = extract_json_from_markdown(result)
|
53
|
+
|
54
|
+
begin
|
55
|
+
result = JSON.parse(cleaned_result)
|
56
|
+
rescue JSON::ParserError => e
|
57
|
+
raise "Failed to parse CodingAgent result as JSON: #{e.message}"
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
25
61
|
# Process output if print_response is enabled
|
26
62
|
process_output(result, print_response:)
|
27
63
|
|
28
64
|
# Apply coercion if configured
|
29
65
|
apply_coercion(result)
|
30
66
|
end
|
67
|
+
|
68
|
+
private
|
69
|
+
|
70
|
+
def extract_json_from_markdown(text)
|
71
|
+
# Look for JSON code blocks anywhere in the text
|
72
|
+
# Matches ```json or ``` followed by content, then closing ```
|
73
|
+
json_block_pattern = /```(?:json)?\s*\n(.*?)\n```/m
|
74
|
+
|
75
|
+
match = text.match(json_block_pattern)
|
76
|
+
if match
|
77
|
+
# Return the content inside the code block
|
78
|
+
match[1].strip
|
79
|
+
else
|
80
|
+
# No code block found, return original text
|
81
|
+
text.strip
|
82
|
+
end
|
83
|
+
end
|
31
84
|
end
|
32
85
|
end
|
33
86
|
end
|
@@ -24,6 +24,7 @@ module Roast
|
|
24
24
|
|
25
25
|
delegate :api_provider, :openai?, to: :workflow_configuration, allow_nil: true
|
26
26
|
delegate :output, :output=, :append_to_final_output, :final_output, to: :output_manager
|
27
|
+
delegate :metadata, :metadata=, to: :metadata_manager
|
27
28
|
delegate_missing_to :output
|
28
29
|
|
29
30
|
def initialize(file = nil, name: nil, context_path: nil, resource: nil, session_name: nil, workflow_configuration: nil, pre_processing_data: nil)
|
@@ -38,6 +39,7 @@ module Roast
|
|
38
39
|
|
39
40
|
# Initialize managers
|
40
41
|
@output_manager = OutputManager.new
|
42
|
+
@metadata_manager = MetadataManager.new
|
41
43
|
@context_manager = ContextManager.new
|
42
44
|
@context_management_config = {}
|
43
45
|
|
@@ -125,8 +127,8 @@ module Roast
|
|
125
127
|
self
|
126
128
|
end
|
127
129
|
|
128
|
-
# Expose output
|
129
|
-
attr_reader :output_manager
|
130
|
+
# Expose output and metadata managers for state management
|
131
|
+
attr_reader :output_manager, :metadata_manager
|
130
132
|
|
131
133
|
private
|
132
134
|
|
@@ -14,6 +14,8 @@ module Roast
|
|
14
14
|
end
|
15
15
|
end
|
16
16
|
|
17
|
+
BASH_COMMAND_REGEX = /^\$\((.*)\)$/m
|
18
|
+
|
17
19
|
def initialize(logger: nil)
|
18
20
|
@logger = logger || NullLogger.new
|
19
21
|
end
|
@@ -44,7 +46,7 @@ module Roast
|
|
44
46
|
private
|
45
47
|
|
46
48
|
def extract_command(command_string)
|
47
|
-
match = command_string.strip.match(
|
49
|
+
match = command_string.strip.match(BASH_COMMAND_REGEX)
|
48
50
|
raise ArgumentError, "Invalid command format. Expected $(command), got: #{command_string}" unless match
|
49
51
|
|
50
52
|
match[1]
|
@@ -54,10 +54,12 @@ module Roast
|
|
54
54
|
# Set the variable in the workflow's context
|
55
55
|
workflow.instance_variable_set("@#{@variable_name}", value)
|
56
56
|
|
57
|
-
# Define a getter method for the variable
|
57
|
+
# Define a getter method for the variable if it doesn't exist
|
58
58
|
var_name = @variable_name.to_sym
|
59
|
-
workflow.
|
60
|
-
|
59
|
+
unless workflow.respond_to?(var_name)
|
60
|
+
workflow.singleton_class.class_eval do
|
61
|
+
attr_reader(var_name)
|
62
|
+
end
|
61
63
|
end
|
62
64
|
|
63
65
|
# Make the variable accessible in the output hash
|
@@ -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
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
# Manages workflow metadata, providing a structure parallel to output
|
6
|
+
# but specifically for internal metadata that shouldn't be user-facing
|
7
|
+
class MetadataManager
|
8
|
+
def initialize
|
9
|
+
@metadata = ActiveSupport::HashWithIndifferentAccess.new
|
10
|
+
@metadata_wrapper = nil
|
11
|
+
end
|
12
|
+
|
13
|
+
# Get metadata wrapped in DotAccessHash for dot notation access
|
14
|
+
def metadata
|
15
|
+
@metadata_wrapper ||= DotAccessHash.new(@metadata)
|
16
|
+
end
|
17
|
+
|
18
|
+
# Set metadata, ensuring it's always a HashWithIndifferentAccess
|
19
|
+
def metadata=(value)
|
20
|
+
@metadata = if value.is_a?(ActiveSupport::HashWithIndifferentAccess)
|
21
|
+
value
|
22
|
+
else
|
23
|
+
ActiveSupport::HashWithIndifferentAccess.new(value)
|
24
|
+
end
|
25
|
+
# Reset the wrapper when metadata changes
|
26
|
+
@metadata_wrapper = nil
|
27
|
+
end
|
28
|
+
|
29
|
+
# Get the raw metadata hash (for internal use)
|
30
|
+
def raw_metadata
|
31
|
+
@metadata
|
32
|
+
end
|
33
|
+
|
34
|
+
# Get a snapshot of the current state for persistence
|
35
|
+
def to_h
|
36
|
+
@metadata.to_h
|
37
|
+
end
|
38
|
+
|
39
|
+
# Restore state from a hash
|
40
|
+
def from_h(data)
|
41
|
+
return unless data
|
42
|
+
|
43
|
+
self.metadata = data
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -77,6 +77,7 @@ module Roast
|
|
77
77
|
return unless state_data && @workflow
|
78
78
|
|
79
79
|
restore_output(state_data)
|
80
|
+
restore_metadata(state_data)
|
80
81
|
restore_transcript(state_data)
|
81
82
|
restore_final_output(state_data)
|
82
83
|
end
|
@@ -88,6 +89,13 @@ module Roast
|
|
88
89
|
@workflow.output = state_data[:output]
|
89
90
|
end
|
90
91
|
|
92
|
+
def restore_metadata(state_data)
|
93
|
+
return unless state_data.key?(:metadata)
|
94
|
+
return unless @workflow.respond_to?(:metadata=)
|
95
|
+
|
96
|
+
@workflow.metadata = state_data[:metadata]
|
97
|
+
end
|
98
|
+
|
91
99
|
def restore_transcript(state_data)
|
92
100
|
return unless state_data.key?(:transcript)
|
93
101
|
return unless @workflow.respond_to?(:transcript)
|
@@ -0,0 +1,115 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Roast
|
4
|
+
module Workflow
|
5
|
+
class ShellScriptStep < BaseStep
|
6
|
+
attr_reader :script_path
|
7
|
+
attr_accessor :exit_on_error, :env
|
8
|
+
|
9
|
+
def initialize(workflow, script_path:, **options)
|
10
|
+
super(workflow, **options)
|
11
|
+
@script_path = script_path
|
12
|
+
@exit_on_error = true # default to true
|
13
|
+
@env = {} # custom environment variables
|
14
|
+
end
|
15
|
+
|
16
|
+
def call
|
17
|
+
validate_script!
|
18
|
+
|
19
|
+
stdout, stderr, status = execute_script
|
20
|
+
|
21
|
+
result = if status.success?
|
22
|
+
parse_output(stdout)
|
23
|
+
else
|
24
|
+
handle_script_error(stderr, status.exitstatus)
|
25
|
+
end
|
26
|
+
|
27
|
+
process_output(result, print_response: @print_response)
|
28
|
+
result
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def validate_script!
|
34
|
+
unless File.exist?(script_path)
|
35
|
+
raise ::CLI::Kit::Abort, "Shell script not found: #{script_path}"
|
36
|
+
end
|
37
|
+
|
38
|
+
unless File.executable?(script_path)
|
39
|
+
raise ::CLI::Kit::Abort, "Shell script is not executable: #{script_path}. Run: chmod +x #{script_path}"
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def execute_script
|
44
|
+
env = setup_environment
|
45
|
+
cmd = build_command
|
46
|
+
|
47
|
+
log_debug("Executing shell script: #{cmd}")
|
48
|
+
log_debug("Environment: #{env.inspect}")
|
49
|
+
|
50
|
+
Open3.capture3(env, cmd, chdir: Dir.pwd)
|
51
|
+
end
|
52
|
+
|
53
|
+
def build_command
|
54
|
+
script_path
|
55
|
+
end
|
56
|
+
|
57
|
+
def setup_environment
|
58
|
+
env_vars = {}
|
59
|
+
|
60
|
+
# Add workflow context as environment variables
|
61
|
+
env_vars["ROAST_WORKFLOW_RESOURCE"] = workflow.resource.to_s if workflow.resource
|
62
|
+
env_vars["ROAST_STEP_NAME"] = name.value
|
63
|
+
|
64
|
+
# Add workflow outputs as JSON
|
65
|
+
if workflow.output && !workflow.output.empty?
|
66
|
+
env_vars["ROAST_WORKFLOW_OUTPUT"] = JSON.generate(workflow.output)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Add any custom environment variables from step configuration
|
70
|
+
if @env.is_a?(Hash)
|
71
|
+
@env.each do |key, value|
|
72
|
+
env_vars[key.to_s] = value.to_s
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
env_vars
|
77
|
+
end
|
78
|
+
|
79
|
+
def parse_output(stdout)
|
80
|
+
return "" if stdout.strip.empty?
|
81
|
+
|
82
|
+
if @json
|
83
|
+
begin
|
84
|
+
JSON.parse(stdout.strip)
|
85
|
+
rescue JSON::ParserError => e
|
86
|
+
raise "Failed to parse shell script output as JSON: #{e.message}\nOutput was: #{stdout.strip}"
|
87
|
+
end
|
88
|
+
else
|
89
|
+
stdout.strip
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
def handle_script_error(stderr, exit_code)
|
94
|
+
error_message = "Shell script failed with exit code #{exit_code}"
|
95
|
+
error_message += "\nError output:\n#{stderr}" unless stderr.strip.empty?
|
96
|
+
|
97
|
+
if @exit_on_error == false
|
98
|
+
log_error(error_message)
|
99
|
+
# Return stderr as the result when not exiting on error
|
100
|
+
stderr.strip.empty? ? "" : stderr.strip
|
101
|
+
else
|
102
|
+
raise ::CLI::Kit::Abort, error_message
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
def log_debug(message)
|
107
|
+
$stderr.puts "[ShellScriptStep] #{message}" if ENV["ROAST_DEBUG"]
|
108
|
+
end
|
109
|
+
|
110
|
+
def log_error(message)
|
111
|
+
$stderr.puts "[ShellScriptStep] ERROR: #{message}"
|
112
|
+
end
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|