ruby_llm-agents 0.3.3 → 0.3.5

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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +132 -1263
  3. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
  4. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
  5. data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
  6. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +137 -9
  7. data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
  8. data/app/models/ruby_llm/agents/execution/analytics.rb +103 -12
  9. data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
  10. data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
  11. data/app/models/ruby_llm/agents/execution.rb +28 -59
  12. data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
  13. data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
  14. data/app/views/layouts/ruby_llm/agents/application.html.erb +430 -0
  15. data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
  16. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
  17. data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
  18. data/app/views/ruby_llm/agents/agents/show.html.erb +775 -0
  19. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
  20. data/app/views/ruby_llm/agents/dashboard/_budgets_bar.html.erb +75 -0
  21. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
  22. data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +84 -0
  23. data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
  24. data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +49 -0
  25. data/app/views/ruby_llm/agents/dashboard/index.html.erb +155 -0
  26. data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
  27. data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
  28. data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
  29. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
  30. data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
  31. data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +260 -200
  32. data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +1 -1
  33. data/app/views/ruby_llm/agents/shared/_breadcrumbs.html.erb +48 -0
  34. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
  35. data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
  36. data/app/views/ruby_llm/agents/shared/_nav_link.html.erb +27 -0
  37. data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
  38. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
  39. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
  40. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
  41. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
  42. data/config/routes.rb +2 -0
  43. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
  44. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
  45. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
  46. data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
  47. data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
  48. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
  49. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
  50. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
  51. data/lib/ruby_llm/agents/alert_manager.rb +20 -16
  52. data/lib/ruby_llm/agents/base/caching.rb +40 -0
  53. data/lib/ruby_llm/agents/base/cost_calculation.rb +105 -0
  54. data/lib/ruby_llm/agents/base/dsl.rb +261 -0
  55. data/lib/ruby_llm/agents/base/execution.rb +258 -0
  56. data/lib/ruby_llm/agents/base/reliability_execution.rb +136 -0
  57. data/lib/ruby_llm/agents/base/response_building.rb +86 -0
  58. data/lib/ruby_llm/agents/base/tool_tracking.rb +57 -0
  59. data/lib/ruby_llm/agents/base.rb +37 -801
  60. data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
  61. data/lib/ruby_llm/agents/cache_helper.rb +98 -0
  62. data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
  63. data/lib/ruby_llm/agents/configuration.rb +40 -1
  64. data/lib/ruby_llm/agents/engine.rb +65 -1
  65. data/lib/ruby_llm/agents/inflections.rb +14 -0
  66. data/lib/ruby_llm/agents/instrumentation.rb +66 -0
  67. data/lib/ruby_llm/agents/reliability.rb +8 -2
  68. data/lib/ruby_llm/agents/version.rb +1 -1
  69. data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
  70. data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
  71. data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
  72. data/lib/ruby_llm/agents/workflow/result.rb +390 -0
  73. data/lib/ruby_llm/agents/workflow/router.rb +429 -0
  74. data/lib/ruby_llm/agents/workflow.rb +232 -0
  75. data/lib/ruby_llm/agents.rb +1 -0
  76. metadata +57 -75
  77. data/app/channels/ruby_llm/agents/executions_channel.rb +0 -46
  78. data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +0 -56
  79. data/app/javascript/ruby_llm/agents/controllers/index.js +0 -12
  80. data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +0 -83
  81. data/app/views/layouts/rubyllm/agents/application.html.erb +0 -626
  82. data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
  83. data/app/views/rubyllm/agents/agents/show.html.erb +0 -772
  84. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +0 -165
  85. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +0 -10
  86. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +0 -71
  87. data/app/views/rubyllm/agents/dashboard/index.html.erb +0 -197
  88. data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
  89. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
  90. data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
  91. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
  92. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
  93. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
  94. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
  95. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
  96. /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
  97. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_stat_card.html.erb +0 -0
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ # Instrumentation concern for workflow execution tracking
7
+ #
8
+ # Provides comprehensive workflow tracking including:
9
+ # - Root execution record creation for the workflow
10
+ # - Timing metrics (started_at, completed_at, duration_ms)
11
+ # - Aggregate token usage and cost across all steps/branches
12
+ # - Workflow-specific metadata (workflow_id, workflow_type)
13
+ # - Error handling with proper status updates
14
+ #
15
+ # @api private
16
+ module Instrumentation
17
+ extend ActiveSupport::Concern
18
+
19
+ included do
20
+ # @!attribute [rw] execution_id
21
+ # The ID of the workflow's root execution record
22
+ # @return [Integer, nil]
23
+ attr_accessor :execution_id
24
+ end
25
+
26
+ # Wraps workflow execution with comprehensive metrics tracking
27
+ #
28
+ # Creates a root execution record for the workflow and tracks
29
+ # aggregate metrics from all child executions.
30
+ #
31
+ # @yield The block containing the workflow execution
32
+ # @return [WorkflowResult] The workflow result
33
+ def instrument_workflow(&block)
34
+ started_at = Time.current
35
+ @workflow_started_at = started_at
36
+
37
+ # Create workflow execution record
38
+ execution = create_workflow_execution(started_at)
39
+ @execution_id = execution&.id
40
+ @root_execution_id = execution&.id
41
+
42
+ begin
43
+ result = if self.class.timeout
44
+ Timeout.timeout(self.class.timeout) { yield }
45
+ else
46
+ yield
47
+ end
48
+
49
+ complete_workflow_execution(
50
+ execution,
51
+ completed_at: Time.current,
52
+ status: result.status,
53
+ result: result
54
+ )
55
+
56
+ result
57
+ rescue Timeout::Error => e
58
+ complete_workflow_execution(
59
+ execution,
60
+ completed_at: Time.current,
61
+ status: "timeout",
62
+ error: e
63
+ )
64
+ raise
65
+ rescue WorkflowCostExceededError => e
66
+ complete_workflow_execution(
67
+ execution,
68
+ completed_at: Time.current,
69
+ status: "error",
70
+ error: e
71
+ )
72
+ raise
73
+ rescue StandardError => e
74
+ complete_workflow_execution(
75
+ execution,
76
+ completed_at: Time.current,
77
+ status: "error",
78
+ error: e
79
+ )
80
+ raise
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ # Creates the initial workflow execution record
87
+ #
88
+ # @param started_at [Time] When the workflow started
89
+ # @return [RubyLLM::Agents::Execution, nil] The created record
90
+ def create_workflow_execution(started_at)
91
+ RubyLLM::Agents::Execution.create!(
92
+ agent_type: self.class.name,
93
+ agent_version: self.class.version,
94
+ model_id: "workflow",
95
+ temperature: nil,
96
+ started_at: started_at,
97
+ status: "running",
98
+ parameters: Redactor.redact(options),
99
+ metadata: workflow_metadata,
100
+ workflow_id: workflow_id,
101
+ workflow_type: workflow_type_name
102
+ )
103
+ rescue StandardError => e
104
+ Rails.logger.error("[RubyLLM::Agents::Workflow] Failed to create workflow execution: #{e.message}")
105
+ nil
106
+ end
107
+
108
+ # Updates the workflow execution record with completion data
109
+ #
110
+ # @param execution [Execution, nil] The execution record
111
+ # @param completed_at [Time] When the workflow completed
112
+ # @param status [String] Final status
113
+ # @param result [WorkflowResult, nil] The workflow result
114
+ # @param error [Exception, nil] The error if failed
115
+ def complete_workflow_execution(execution, completed_at:, status:, result: nil, error: nil)
116
+ return unless execution
117
+
118
+ started_at = execution.started_at
119
+ duration_ms = ((completed_at - started_at) * 1000).round
120
+
121
+ update_data = {
122
+ completed_at: completed_at,
123
+ duration_ms: duration_ms,
124
+ status: status
125
+ }
126
+
127
+ # Add aggregate metrics from result
128
+ if result
129
+ update_data.merge!(
130
+ input_tokens: result.input_tokens,
131
+ output_tokens: result.output_tokens,
132
+ total_tokens: result.total_tokens,
133
+ cached_tokens: result.cached_tokens,
134
+ input_cost: result.input_cost,
135
+ output_cost: result.output_cost,
136
+ total_cost: result.total_cost
137
+ )
138
+
139
+ # Store step/branch results summary
140
+ update_data[:response] = build_response_summary(result)
141
+ end
142
+
143
+ # Add error data if failed
144
+ if error
145
+ update_data.merge!(
146
+ error_class: error.class.name,
147
+ error_message: error.message.to_s.truncate(65535)
148
+ )
149
+ end
150
+
151
+ execution.update!(update_data)
152
+ rescue StandardError => e
153
+ Rails.logger.error("[RubyLLM::Agents::Workflow] Failed to update workflow execution #{execution&.id}: #{e.message}")
154
+ mark_workflow_failed!(execution, error: error || e)
155
+ end
156
+
157
+ # Emergency fallback to mark workflow as failed
158
+ #
159
+ # @param execution [Execution, nil] The execution record
160
+ # @param error [Exception, nil] The error
161
+ def mark_workflow_failed!(execution, error: nil)
162
+ return unless execution&.id
163
+
164
+ update_data = {
165
+ status: "error",
166
+ completed_at: Time.current,
167
+ error_class: error&.class&.name || "UnknownError",
168
+ error_message: error&.message&.to_s&.truncate(65535) || "Unknown error"
169
+ }
170
+
171
+ execution.class.where(id: execution.id, status: "running").update_all(update_data)
172
+ rescue StandardError => e
173
+ Rails.logger.error("[RubyLLM::Agents::Workflow] CRITICAL: Failed to mark workflow #{execution&.id} as failed: #{e.message}")
174
+ end
175
+
176
+ # Builds a summary of step/branch results for storage
177
+ #
178
+ # @param result [WorkflowResult] The workflow result
179
+ # @return [Hash] Summary data
180
+ def build_response_summary(result)
181
+ summary = {
182
+ workflow_type: result.workflow_type,
183
+ status: result.status
184
+ }
185
+
186
+ if result.steps.any?
187
+ summary[:steps] = result.steps.transform_values do |r|
188
+ {
189
+ status: r.respond_to?(:success?) ? (r.success? ? "success" : "error") : "unknown",
190
+ total_cost: r.respond_to?(:total_cost) ? r.total_cost : 0,
191
+ duration_ms: r.respond_to?(:duration_ms) ? r.duration_ms : nil
192
+ }
193
+ end
194
+ end
195
+
196
+ if result.branches.any?
197
+ summary[:branches] = result.branches.transform_values do |r|
198
+ next { status: "error" } if r.nil?
199
+
200
+ {
201
+ status: r.respond_to?(:success?) ? (r.success? ? "success" : "error") : "unknown",
202
+ total_cost: r.respond_to?(:total_cost) ? r.total_cost : 0,
203
+ duration_ms: r.respond_to?(:duration_ms) ? r.duration_ms : nil
204
+ }
205
+ end
206
+ end
207
+
208
+ if result.routed_to
209
+ summary[:routed_to] = result.routed_to
210
+ summary[:classification_cost] = result.classification_cost
211
+ end
212
+
213
+ summary
214
+ end
215
+
216
+ # Returns workflow-specific metadata
217
+ #
218
+ # @return [Hash] Workflow metadata
219
+ def workflow_metadata
220
+ base_metadata = {
221
+ workflow_id: workflow_id,
222
+ workflow_type: workflow_type_name
223
+ }
224
+
225
+ # Allow subclasses to add custom metadata
226
+ if respond_to?(:execution_metadata, true)
227
+ base_metadata.merge(execution_metadata)
228
+ else
229
+ base_metadata
230
+ end
231
+ end
232
+
233
+ # Returns the workflow type name for storage
234
+ #
235
+ # @return [String] The workflow type (pipeline, parallel, router)
236
+ def workflow_type_name
237
+ case self
238
+ when Workflow::Pipeline then "pipeline"
239
+ when Workflow::Parallel then "parallel"
240
+ when Workflow::Router then "router"
241
+ else "workflow"
242
+ end
243
+ end
244
+
245
+ # Hook for subclasses to add custom metadata
246
+ #
247
+ # @return [Hash] Custom metadata
248
+ def execution_metadata
249
+ {}
250
+ end
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ # Concurrent workflow execution pattern
7
+ #
8
+ # Executes multiple agents simultaneously and aggregates their results.
9
+ # Supports fail-fast behavior, optional branches, and custom aggregation.
10
+ #
11
+ # @example Basic parallel execution
12
+ # class ReviewAnalyzer < RubyLLM::Agents::Workflow::Parallel
13
+ # version "1.0"
14
+ #
15
+ # branch :sentiment, agent: SentimentAgent
16
+ # branch :summary, agent: SummaryAgent
17
+ # branch :categories, agent: CategoryAgent
18
+ # end
19
+ #
20
+ # result = ReviewAnalyzer.call(text: "Great product!")
21
+ # result.branches[:sentiment].content # "positive"
22
+ # result.branches[:summary].content # "User liked the product"
23
+ #
24
+ # @example With optional branches and custom aggregation
25
+ # class FullAnalyzer < RubyLLM::Agents::Workflow::Parallel
26
+ # branch :sentiment, agent: SentimentAgent
27
+ # branch :toxicity, agent: ToxicityAgent, optional: true
28
+ #
29
+ # def aggregate(results)
30
+ # {
31
+ # sentiment: results[:sentiment]&.content,
32
+ # toxicity: results[:toxicity]&.content,
33
+ # safe: results[:toxicity]&.content != "toxic"
34
+ # }
35
+ # end
36
+ # end
37
+ #
38
+ # @example With fail-fast enabled
39
+ # class CriticalAnalyzer < RubyLLM::Agents::Workflow::Parallel
40
+ # fail_fast true # Stop all branches on first failure
41
+ #
42
+ # branch :auth, agent: AuthValidator
43
+ # branch :sanity, agent: SanityChecker
44
+ # end
45
+ #
46
+ # @api public
47
+ class Parallel < Workflow
48
+ class << self
49
+ # Returns the defined branches
50
+ #
51
+ # @return [Hash<Symbol, Hash>] Branch configurations
52
+ def branches
53
+ @branches ||= {}
54
+ end
55
+
56
+ # Inherits branches from parent class
57
+ def inherited(subclass)
58
+ super
59
+ subclass.instance_variable_set(:@branches, branches.dup)
60
+ subclass.instance_variable_set(:@fail_fast, @fail_fast)
61
+ subclass.instance_variable_set(:@concurrency, @concurrency)
62
+ end
63
+
64
+ # Defines a parallel branch
65
+ #
66
+ # @param name [Symbol] Branch identifier
67
+ # @param agent [Class] The agent class to execute
68
+ # @param optional [Boolean] If true, branch failure won't fail the workflow
69
+ # @param input [Proc, nil] Lambda to transform input for this branch
70
+ # @return [void]
71
+ #
72
+ # @example Basic branch
73
+ # branch :analyze, agent: AnalyzerAgent
74
+ #
75
+ # @example Optional branch
76
+ # branch :enrich, agent: EnricherAgent, optional: true
77
+ #
78
+ # @example With custom input
79
+ # branch :summarize, agent: SummaryAgent, input: ->(opts) { { text: opts[:content] } }
80
+ def branch(name, agent:, optional: false, input: nil)
81
+ branches[name] = {
82
+ agent: agent,
83
+ optional: optional,
84
+ input: input
85
+ }
86
+ end
87
+
88
+ # Sets or returns fail-fast behavior
89
+ #
90
+ # When true, cancels remaining branches when any required branch fails.
91
+ #
92
+ # @param value [Boolean, nil] Whether to fail fast
93
+ # @return [Boolean] Current fail-fast setting
94
+ def fail_fast(value = nil)
95
+ if value.nil?
96
+ @fail_fast || false
97
+ else
98
+ @fail_fast = value
99
+ end
100
+ end
101
+
102
+ # Alias for checking fail_fast setting
103
+ def fail_fast?
104
+ fail_fast
105
+ end
106
+
107
+ # Sets or returns concurrency limit
108
+ #
109
+ # @param value [Integer, nil] Max concurrent branches (nil = unlimited)
110
+ # @return [Integer, nil] Current concurrency limit
111
+ def concurrency(value = nil)
112
+ if value.nil?
113
+ @concurrency
114
+ else
115
+ @concurrency = value
116
+ end
117
+ end
118
+ end
119
+
120
+ # Executes the parallel workflow
121
+ #
122
+ # Runs all branches concurrently and aggregates results.
123
+ #
124
+ # @yield [chunk] Yields chunks when streaming (not typically used in parallel)
125
+ # @return [WorkflowResult] The parallel result
126
+ def call(&block)
127
+ instrument_workflow do
128
+ execute_parallel(&block)
129
+ end
130
+ end
131
+
132
+ # Aggregates branch results into final content
133
+ #
134
+ # Override this method to customize how results are combined.
135
+ #
136
+ # @param results [Hash<Symbol, Result>] Branch results
137
+ # @return [Object] Aggregated content
138
+ def aggregate(results)
139
+ # Default: return hash of branch contents
140
+ results.transform_values { |r| r&.content }
141
+ end
142
+
143
+ private
144
+
145
+ # Executes all branches in parallel
146
+ #
147
+ # @return [WorkflowResult] The parallel result
148
+ def execute_parallel(&block)
149
+ results = {}
150
+ errors = {}
151
+ mutex = Mutex.new
152
+ should_abort = false
153
+
154
+ # Create thread pool based on concurrency setting
155
+ threads = self.class.branches.map do |name, config|
156
+ Thread.new do
157
+ Thread.current.name = "parallel-#{name}"
158
+ Thread.current[:branch_name] = name
159
+
160
+ begin
161
+ # Check if we should abort early
162
+ if self.class.fail_fast? && should_abort
163
+ mutex.synchronize { results[name] = nil }
164
+ next
165
+ end
166
+
167
+ # Build input for this branch
168
+ branch_input = build_branch_input(name, config)
169
+
170
+ # Execute the branch
171
+ result = execute_agent(
172
+ config[:agent],
173
+ branch_input,
174
+ step_name: name,
175
+ &block
176
+ )
177
+
178
+ mutex.synchronize do
179
+ results[name] = result
180
+
181
+ # Check for failure
182
+ if result.respond_to?(:error?) && result.error? && !config[:optional]
183
+ should_abort = true if self.class.fail_fast?
184
+ end
185
+ end
186
+ rescue StandardError => e
187
+ mutex.synchronize do
188
+ errors[name] = e
189
+ results[name] = nil
190
+ should_abort = true if self.class.fail_fast? && !config[:optional]
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ # Apply concurrency limit if set
197
+ if self.class.concurrency
198
+ threads.each_slice(self.class.concurrency) do |thread_batch|
199
+ thread_batch.each(&:join)
200
+ end
201
+ else
202
+ threads.each(&:join)
203
+ end
204
+
205
+ # Determine overall status
206
+ status = determine_parallel_status(results, errors)
207
+
208
+ # Aggregate results
209
+ final_content = begin
210
+ aggregate(results)
211
+ rescue StandardError => e
212
+ errors[:aggregate] = e
213
+ results.transform_values { |r| r&.content }
214
+ end
215
+
216
+ build_parallel_result(
217
+ content: final_content,
218
+ branches: results,
219
+ errors: errors,
220
+ status: status
221
+ )
222
+ end
223
+
224
+ # Builds input for a specific branch
225
+ #
226
+ # @param name [Symbol] Branch name
227
+ # @param config [Hash] Branch configuration
228
+ # @return [Hash] Input for the branch
229
+ def build_branch_input(name, config)
230
+ if config[:input]
231
+ config[:input].call(options)
232
+ elsif respond_to?(:"before_#{name}", true)
233
+ send(:"before_#{name}", options)
234
+ else
235
+ options.dup
236
+ end
237
+ end
238
+
239
+ # Determines the overall parallel status
240
+ #
241
+ # @param results [Hash] Branch results
242
+ # @param errors [Hash] Branch errors
243
+ # @return [String] Status: "success", "partial", or "error"
244
+ def determine_parallel_status(results, errors)
245
+ required_branches = self.class.branches.reject { |_, c| c[:optional] }.keys
246
+ failed_required = required_branches.select do |name|
247
+ errors[name] || (results[name].respond_to?(:error?) && results[name].error?)
248
+ end
249
+
250
+ if failed_required.any?
251
+ "error"
252
+ elsif errors.any?
253
+ "partial"
254
+ else
255
+ "success"
256
+ end
257
+ end
258
+
259
+ # Builds the final parallel result
260
+ #
261
+ # @param content [Object] Aggregated content
262
+ # @param branches [Hash] Branch results
263
+ # @param errors [Hash] Branch errors
264
+ # @param status [String] Final status
265
+ # @return [WorkflowResult] The parallel result
266
+ def build_parallel_result(content:, branches:, errors:, status:)
267
+ Workflow::Result.new(
268
+ content: content,
269
+ workflow_type: self.class.name,
270
+ workflow_id: workflow_id,
271
+ branches: branches,
272
+ errors: errors,
273
+ status: status,
274
+ started_at: @workflow_started_at,
275
+ completed_at: Time.current,
276
+ duration_ms: (((Time.current - @workflow_started_at) * 1000).round if @workflow_started_at)
277
+ )
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end