igniter 0.3.0 → 0.4.0

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +2 -2
  4. data/docs/API_V2.md +58 -0
  5. data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
  6. data/examples/README.md +3 -0
  7. data/examples/distributed_workflow.rb +52 -0
  8. data/examples/ringcentral_routing.rb +26 -35
  9. data/lib/igniter/compiler/compiled_graph.rb +20 -0
  10. data/lib/igniter/compiler/validation_pipeline.rb +3 -1
  11. data/lib/igniter/compiler/validators/await_validator.rb +53 -0
  12. data/lib/igniter/compiler/validators/dependencies_validator.rb +43 -1
  13. data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
  14. data/lib/igniter/compiler.rb +2 -0
  15. data/lib/igniter/contract.rb +75 -8
  16. data/lib/igniter/diagnostics/report.rb +102 -3
  17. data/lib/igniter/dsl/contract_builder.rb +109 -8
  18. data/lib/igniter/errors.rb +6 -1
  19. data/lib/igniter/extensions/introspection/graph_formatter.rb +4 -0
  20. data/lib/igniter/integrations/llm/config.rb +69 -0
  21. data/lib/igniter/integrations/llm/context.rb +74 -0
  22. data/lib/igniter/integrations/llm/executor.rb +159 -0
  23. data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
  24. data/lib/igniter/integrations/llm/providers/base.rb +33 -0
  25. data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
  26. data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
  27. data/lib/igniter/integrations/llm.rb +59 -0
  28. data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
  29. data/lib/igniter/integrations/rails/contract_job.rb +76 -0
  30. data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
  31. data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
  32. data/lib/igniter/integrations/rails/railtie.rb +25 -0
  33. data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
  34. data/lib/igniter/integrations/rails.rb +12 -0
  35. data/lib/igniter/model/await_node.rb +21 -0
  36. data/lib/igniter/model/branch_node.rb +9 -3
  37. data/lib/igniter/model/collection_node.rb +9 -3
  38. data/lib/igniter/model/remote_node.rb +26 -0
  39. data/lib/igniter/model.rb +2 -0
  40. data/lib/igniter/runtime/execution.rb +2 -2
  41. data/lib/igniter/runtime/input_validator.rb +5 -3
  42. data/lib/igniter/runtime/resolver.rb +91 -8
  43. data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
  44. data/lib/igniter/runtime/stores/file_store.rb +50 -2
  45. data/lib/igniter/runtime/stores/memory_store.rb +55 -2
  46. data/lib/igniter/runtime/stores/redis_store.rb +13 -1
  47. data/lib/igniter/server/client.rb +123 -0
  48. data/lib/igniter/server/config.rb +27 -0
  49. data/lib/igniter/server/handlers/base.rb +105 -0
  50. data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
  51. data/lib/igniter/server/handlers/event_handler.rb +28 -0
  52. data/lib/igniter/server/handlers/execute_handler.rb +37 -0
  53. data/lib/igniter/server/handlers/health_handler.rb +32 -0
  54. data/lib/igniter/server/handlers/status_handler.rb +27 -0
  55. data/lib/igniter/server/http_server.rb +109 -0
  56. data/lib/igniter/server/rack_app.rb +35 -0
  57. data/lib/igniter/server/registry.rb +56 -0
  58. data/lib/igniter/server/router.rb +75 -0
  59. data/lib/igniter/server.rb +67 -0
  60. data/lib/igniter/version.rb +1 -1
  61. data/lib/igniter.rb +4 -0
  62. metadata +36 -2
@@ -104,29 +104,26 @@ class CallConnectedContract < Igniter::Contract
104
104
  active_calls.any?
105
105
  end
106
106
 
107
- map :call_inputs, from: :active_calls do |active_calls:|
108
- active_calls.map do |call|
109
- {
110
- session_id: call.fetch("telephonySessionId"),
111
- direction: call.fetch("direction"),
112
- from: call.fetch("from"),
113
- to: call.fetch("to"),
114
- start_time: call.fetch("startTime")
115
- }
116
- end
117
- end
118
-
119
107
  collection :calls,
120
- with: :call_inputs,
108
+ with: :active_calls,
121
109
  each: CallEventContract,
122
110
  key: :session_id,
123
- mode: :collect
111
+ mode: :collect,
112
+ map_inputs: lambda { |item:|
113
+ {
114
+ session_id: item.fetch("telephonySessionId"),
115
+ direction: item.fetch("direction"),
116
+ from: item.fetch("from"),
117
+ to: item.fetch("to"),
118
+ start_time: item.fetch("startTime")
119
+ }
120
+ }
124
121
 
125
122
  compute :call_summaries, with: :calls do |calls:|
126
123
  calls.successes.values.map { |item| item.result.summary }
127
124
  end
128
125
 
129
- compute :routing_summary, with: %i[calls call_summaries extension_id telephony_status has_calls] do |calls:, call_summaries:, extension_id:, telephony_status:, has_calls:|
126
+ aggregate :routing_summary, with: %i[calls call_summaries extension_id telephony_status has_calls] do |calls:, call_summaries:, extension_id:, telephony_status:, has_calls:|
130
127
  has_calls
131
128
  {
132
129
  extension_id: extension_id,
@@ -204,28 +201,22 @@ class RingcentralWebhookContract < Igniter::Contract
204
201
  input :payload
205
202
 
206
203
  scope :parse do
207
- map :body, from: :payload do |payload:|
208
- payload.fetch("body", {})
209
- end
210
-
211
- map :telephony_status, from: :body do |body:|
212
- body["telephonyStatus"]
213
- end
214
-
215
- map :extension_id, from: :body do |body:|
216
- body["extensionId"]
217
- end
218
-
219
- map :active_calls, from: :body do |body:|
220
- body["activeCalls"] || []
221
- end
204
+ project :body, from: :payload, key: :body, default: {}
205
+ project :telephony_status, from: :body, key: "telephonyStatus"
206
+ project :extension_id, from: :body, key: "extensionId"
207
+ project :active_calls, from: :body, key: "activeCalls", default: []
222
208
  end
223
209
 
224
- branch :status_route, with: :telephony_status, inputs: {
225
- extension_id: :extension_id,
226
- telephony_status: :telephony_status,
227
- active_calls: :active_calls
228
- } do
210
+ branch :status_route,
211
+ with: :telephony_status,
212
+ depends_on: %i[extension_id active_calls],
213
+ map_inputs: lambda { |selector:, extension_id:, active_calls:|
214
+ {
215
+ extension_id: extension_id,
216
+ telephony_status: selector,
217
+ active_calls: active_calls
218
+ }
219
+ } do
229
220
  on "CallConnected", contract: CallConnectedContract
230
221
  on "NoCall", contract: NoCallContract
231
222
  on "Ringing", contract: RingingContract
@@ -49,6 +49,10 @@ module Igniter
49
49
  raise KeyError, "Unknown dependency '#{name}'"
50
50
  end
51
51
 
52
+ def await_nodes
53
+ @nodes.select { |n| n.kind == :await }
54
+ end
55
+
52
56
  def to_h
53
57
  {
54
58
  name: name,
@@ -66,16 +70,21 @@ module Igniter
66
70
  end
67
71
  if node.kind == :branch
68
72
  base[:selector] = node.selector_dependency
73
+ base[:depends_on] = node.context_dependencies if node.context_dependencies.any?
69
74
  base[:cases] = node.cases.map { |entry| { match: entry[:match], contract: entry[:contract].name } }
70
75
  base[:default_contract] = node.default_contract.name
71
76
  base[:inputs] = node.input_mapping
77
+ base[:mapper] = node.input_mapper.to_s if node.input_mapper?
72
78
  end
73
79
  if node.kind == :collection
74
80
  base[:with] = node.source_dependency
81
+ base[:depends_on] = node.context_dependencies if node.context_dependencies.any?
75
82
  base[:each] = node.contract_class.name
76
83
  base[:key] = node.key_name
77
84
  base[:mode] = node.mode
85
+ base[:mapper] = node.input_mapper.to_s if node.input_mapper?
78
86
  end
87
+ base[:event] = node.event_name if node.kind == :await
79
88
  base
80
89
  end,
81
90
  outputs: outputs.map do |output|
@@ -113,11 +122,20 @@ module Igniter
113
122
  metadata: node.metadata.reject { |key, _| key == :source_location }
114
123
  }
115
124
  end,
125
+ awaits: nodes.select { |node| node.kind == :await }.map do |node|
126
+ {
127
+ name: node.name,
128
+ event: node.event_name,
129
+ metadata: node.metadata.reject { |key, _| key == :source_location }
130
+ }
131
+ end,
116
132
  branches: nodes.select { |node| node.kind == :branch }.map do |node|
117
133
  {
118
134
  name: node.name,
119
135
  with: node.selector_dependency,
136
+ depends_on: node.context_dependencies,
120
137
  inputs: node.input_mapping,
138
+ map_inputs: (node.input_mapper if node.input_mapper?),
121
139
  cases: node.cases.map { |entry| { on: entry[:match], contract: entry[:contract] } },
122
140
  default: node.default_contract,
123
141
  metadata: node.metadata.reject { |key, _| key == :source_location }
@@ -127,9 +145,11 @@ module Igniter
127
145
  {
128
146
  name: node.name,
129
147
  with: node.source_dependency,
148
+ depends_on: node.context_dependencies,
130
149
  each: node.contract_class,
131
150
  key: node.key_name,
132
151
  mode: node.mode,
152
+ map_inputs: (node.input_mapper if node.input_mapper?),
133
153
  metadata: node.metadata.reject { |key, _| key == :source_location }
134
154
  }
135
155
  end,
@@ -8,7 +8,9 @@ module Igniter
8
8
  Validators::OutputsValidator,
9
9
  Validators::DependenciesValidator,
10
10
  Validators::TypeCompatibilityValidator,
11
- Validators::CallableValidator
11
+ Validators::CallableValidator,
12
+ Validators::AwaitValidator,
13
+ Validators::RemoteValidator
12
14
  ].freeze
13
15
 
14
16
  def self.call(context, validators: DEFAULT_VALIDATORS)
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Compiler
5
+ module Validators
6
+ class AwaitValidator
7
+ def self.call(context)
8
+ new(context).call
9
+ end
10
+
11
+ def initialize(context)
12
+ @context = context
13
+ end
14
+
15
+ def call
16
+ await_nodes = @context.runtime_nodes.select { |n| n.kind == :await }
17
+ return if await_nodes.empty?
18
+
19
+ validate_correlation_keys_as_inputs!(await_nodes)
20
+ validate_unique_event_names!(await_nodes)
21
+ end
22
+
23
+ private
24
+
25
+ def validate_correlation_keys_as_inputs!(await_nodes) # rubocop:disable Metrics/AbcSize
26
+ correlation_keys = @context.graph.metadata[:correlation_keys] || []
27
+ return if correlation_keys.empty?
28
+
29
+ input_names = @context.runtime_nodes.select { |n| n.kind == :input }.map(&:name)
30
+ missing = correlation_keys.reject { |key| input_names.include?(key.to_sym) }
31
+ return if missing.empty?
32
+
33
+ raise @context.validation_error(
34
+ await_nodes.first,
35
+ "Correlation keys #{missing.inspect} must be declared as inputs"
36
+ )
37
+ end
38
+
39
+ def validate_unique_event_names!(await_nodes)
40
+ event_names = await_nodes.map(&:event_name)
41
+ duplicates = event_names.select { |e| event_names.count(e) > 1 }.uniq
42
+ return if duplicates.empty?
43
+
44
+ node = await_nodes.find { |n| duplicates.include?(n.event_name) }
45
+ raise @context.validation_error(
46
+ node,
47
+ "Duplicate await event names: #{duplicates.inspect}"
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -12,8 +12,10 @@ module Igniter
12
12
  @context = context
13
13
  end
14
14
 
15
- def call
15
+ def call # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
16
16
  @context.runtime_nodes.each do |node|
17
+ next if node.kind == :await
18
+
17
19
  validate_composition_node!(node) if node.kind == :composition
18
20
  validate_branch_node!(node) if node.kind == :branch
19
21
  validate_collection_node!(node) if node.kind == :collection
@@ -40,6 +42,7 @@ module Igniter
40
42
  end
41
43
 
42
44
  validate_composition_input_mapping!(node, contract_class.compiled_graph)
45
+ validate_composition_cycle!(node)
43
46
  end
44
47
 
45
48
  def validate_composition_input_mapping!(node, child_graph)
@@ -100,6 +103,8 @@ module Igniter
100
103
  end
101
104
 
102
105
  def validate_branch_input_mapping!(node, child_graph)
106
+ return if node.input_mapper?
107
+
103
108
  child_input_nodes = child_graph.nodes.select { |child_node| child_node.kind == :input }
104
109
  child_input_names = child_input_nodes.map(&:name)
105
110
 
@@ -124,6 +129,43 @@ module Igniter
124
129
  )
125
130
  end
126
131
 
132
+ def validate_composition_cycle!(node)
133
+ child_contract = node.contract_class
134
+ return unless child_contract.respond_to?(:compiled_graph) && child_contract.compiled_graph
135
+
136
+ current_name = @context.graph.name
137
+ # Skip anonymous contracts to avoid false positives when multiple
138
+ # anonymous contracts share the same name "AnonymousContract"
139
+ return if current_name == "AnonymousContract"
140
+
141
+ validate_direct_cycle!(node, child_contract, current_name)
142
+ validate_grandchild_cycles!(node, child_contract, current_name)
143
+ end
144
+
145
+ def validate_direct_cycle!(node, child_contract, current_name)
146
+ return unless child_contract.compiled_graph.name == current_name
147
+
148
+ raise @context.validation_error(
149
+ node,
150
+ "Composition cycle: '#{node.name}' composes '#{child_contract.name}' " \
151
+ "which is the same contract ('#{current_name}')"
152
+ )
153
+ end
154
+
155
+ def validate_grandchild_cycles!(node, child_contract, current_name) # rubocop:disable Metrics/AbcSize
156
+ child_contract.compiled_graph.nodes.select { |n| n.kind == :composition }.each do |grandchild|
157
+ next unless grandchild.contract_class.respond_to?(:compiled_graph)
158
+ next unless grandchild.contract_class.compiled_graph
159
+ next unless grandchild.contract_class.compiled_graph.name == current_name
160
+
161
+ raise @context.validation_error(
162
+ node,
163
+ "Composition cycle: '#{node.name}' -> '#{child_contract.name}' -> " \
164
+ "'#{grandchild.contract_class.name}' loops back to '#{current_name}'"
165
+ )
166
+ end
167
+ end
168
+
127
169
  def validate_collection_node!(node)
128
170
  unless node.contract_class.is_a?(Class) && node.contract_class <= Igniter::Contract
129
171
  raise @context.validation_error(node, "Collection '#{node.name}' must reference an Igniter::Contract subclass")
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Compiler
5
+ module Validators
6
+ class RemoteValidator
7
+ def self.call(context)
8
+ new(context).call
9
+ end
10
+
11
+ def initialize(context)
12
+ @context = context
13
+ end
14
+
15
+ def call
16
+ @context.runtime_nodes.each do |node|
17
+ next unless node.kind == :remote
18
+
19
+ validate_url!(node)
20
+ validate_contract_name!(node)
21
+ validate_dependencies!(node)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def validate_url!(node)
28
+ return if node.node_url.start_with?("http://", "https://")
29
+
30
+ raise @context.validation_error(
31
+ node,
32
+ "remote :#{node.name} has invalid node: URL '#{node.node_url}'. Must start with http:// or https://"
33
+ )
34
+ end
35
+
36
+ def validate_contract_name!(node)
37
+ return unless node.contract_name.strip.empty?
38
+
39
+ raise @context.validation_error(
40
+ node,
41
+ "remote :#{node.name} requires a non-empty contract: name"
42
+ )
43
+ end
44
+
45
+ def validate_dependencies!(node)
46
+ node.dependencies.each do |dep_name|
47
+ next if @context.dependency_resolvable?(dep_name)
48
+
49
+ raise @context.validation_error(
50
+ node,
51
+ "remote :#{node.name} depends on '#{dep_name}' which is not defined in the graph"
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -8,6 +8,8 @@ require_relative "compiler/validators/outputs_validator"
8
8
  require_relative "compiler/validators/dependencies_validator"
9
9
  require_relative "compiler/validators/callable_validator"
10
10
  require_relative "compiler/validators/type_compatibility_validator"
11
+ require_relative "compiler/validators/await_validator"
12
+ require_relative "compiler/validators/remote_validator"
11
13
  require_relative "compiler/validation_pipeline"
12
14
  require_relative "compiler/validator"
13
15
  require_relative "compiler/graph_compiler"
@@ -3,8 +3,20 @@
3
3
  module Igniter
4
4
  class Contract
5
5
  class << self
6
+ def correlate_by(*keys)
7
+ @correlation_keys = keys.map(&:to_sym).freeze
8
+ end
9
+
10
+ def correlation_keys
11
+ @correlation_keys || []
12
+ end
13
+
6
14
  def define(&block)
7
- @compiled_graph = DSL::ContractBuilder.compile(name: contract_name, &block)
15
+ @compiled_graph = DSL::ContractBuilder.compile(
16
+ name: contract_name,
17
+ correlation_keys: correlation_keys,
18
+ &block
19
+ )
8
20
  end
9
21
 
10
22
  def run_with(runner:, max_workers: nil)
@@ -28,6 +40,45 @@ module Igniter
28
40
  @compiled_graph = DSL::SchemaBuilder.compile(schema, name: contract_name)
29
41
  end
30
42
 
43
+ def start(inputs = {}, store: nil, **keyword_inputs)
44
+ resolved_store = store || Igniter.execution_store
45
+ all_inputs = inputs.merge(keyword_inputs)
46
+
47
+ instance = new(all_inputs, runner: :store, store: resolved_store)
48
+ instance.resolve_all
49
+
50
+ correlation = correlation_keys.each_with_object({}) do |key, hash|
51
+ hash[key] = all_inputs[key] || all_inputs[key.to_s]
52
+ end
53
+
54
+ resolved_store.save(instance.snapshot, correlation: correlation.compact, graph: contract_name)
55
+ instance
56
+ end
57
+
58
+ def deliver_event(event_name, correlation:, payload:, store: nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
59
+ resolved_store = store || Igniter.execution_store
60
+ execution_id = resolved_store.find_by_correlation(
61
+ graph: contract_name,
62
+ correlation: correlation.transform_keys(&:to_sym)
63
+ )
64
+ unless execution_id
65
+ raise ResolutionError,
66
+ "No pending execution found for #{contract_name} with given correlation"
67
+ end
68
+
69
+ instance = restore_from_store(execution_id, store: resolved_store)
70
+
71
+ await_node = instance.execution.compiled_graph.await_nodes
72
+ .find { |n| n.event_name == event_name.to_sym }
73
+ raise ResolutionError, "No await node found for event '#{event_name}' in #{contract_name}" unless await_node
74
+
75
+ instance.execution.resume(await_node.name, value: payload)
76
+ instance.resolve_all
77
+
78
+ resolved_store.save(instance.snapshot, correlation: correlation.transform_keys(&:to_sym), graph: contract_name)
79
+ instance
80
+ end
81
+
31
82
  def restore(snapshot)
32
83
  instance = new(
33
84
  snapshot[:inputs] || snapshot["inputs"] || {},
@@ -103,6 +154,13 @@ module Igniter
103
154
  react_to(:execution_failed, once_per_execution: true, &terminal_hook)
104
155
  end
105
156
 
157
+ def present(output_name, with: nil, &block)
158
+ raise CompileError, "present requires a block or `with:`" unless block || with
159
+ raise CompileError, "present cannot use both a block and `with:`" if block && with
160
+
161
+ own_output_presenters[output_name.to_sym] = with || block
162
+ end
163
+
106
164
  def compiled_graph
107
165
  @compiled_graph || superclass_compiled_graph
108
166
  end
@@ -116,8 +174,17 @@ module Igniter
116
174
  @execution_options || superclass_execution_options || {}
117
175
  end
118
176
 
177
+ def output_presenters
178
+ inherited = superclass.respond_to?(:output_presenters) ? superclass.output_presenters : {}
179
+ inherited.merge(own_output_presenters)
180
+ end
181
+
119
182
  private
120
183
 
184
+ def own_output_presenters
185
+ @output_presenters ||= {}
186
+ end
187
+
121
188
  def contract_name
122
189
  name || "AnonymousContract"
123
190
  end
@@ -143,9 +210,9 @@ module Igniter
143
210
  end
144
211
  end
145
212
 
146
- attr_reader :execution, :result
213
+ attr_reader :execution, :result, :reactive
147
214
 
148
- def initialize(inputs = nil, runner: nil, max_workers: nil, **keyword_inputs)
215
+ def initialize(inputs = nil, runner: nil, max_workers: nil, store: nil, **keyword_inputs)
149
216
  graph = self.class.compiled_graph
150
217
  raise CompileError, "#{self.class.name} has no compiled graph. Use `define`." unless graph
151
218
 
@@ -159,7 +226,7 @@ module Igniter
159
226
  end
160
227
 
161
228
  execution_options = self.class.execution_options.merge(
162
- { runner: runner, max_workers: max_workers }.compact
229
+ { runner: runner, max_workers: max_workers, store: store }.compact
163
230
  )
164
231
  execution_options[:store] ||= Igniter.execution_store if execution_options[:runner]&.to_sym == :store
165
232
 
@@ -204,10 +271,6 @@ module Igniter
204
271
  execution.audit.snapshot
205
272
  end
206
273
 
207
- def reactive
208
- @reactive
209
- end
210
-
211
274
  def subscribe(subscriber = nil, &block)
212
275
  execution.events.subscribe(subscriber, &block)
213
276
  self
@@ -245,5 +308,9 @@ module Igniter
245
308
  def failed?
246
309
  execution.failed?
247
310
  end
311
+
312
+ def pending?
313
+ execution.pending?
314
+ end
248
315
  end
249
316
  end
@@ -32,7 +32,7 @@ module Igniter
32
32
  lines << "Diagnostics #{report[:graph]}"
33
33
  lines << "Execution #{report[:execution_id]}"
34
34
  lines << "Status: #{report[:status]}"
35
- lines << format_outputs(report[:outputs])
35
+ lines << format_outputs(presented_outputs)
36
36
  lines << format_nodes(report[:nodes])
37
37
  lines << format_collection_nodes(report[:collection_nodes])
38
38
  lines << format_errors(report[:errors])
@@ -47,7 +47,7 @@ module Igniter
47
47
  lines << ""
48
48
  lines << "- Execution: `#{report[:execution_id]}`"
49
49
  lines << "- Status: `#{report[:status]}`"
50
- lines << "- Outputs: #{inline_hash(report[:outputs])}"
50
+ lines << "- Outputs: #{inline_hash(presented_outputs)}"
51
51
  lines << "- Nodes: total=#{report[:nodes][:total]}, succeeded=#{report[:nodes][:succeeded]}, failed=#{report[:nodes][:failed]}, stale=#{report[:nodes][:stale]}"
52
52
  unless report[:collection_nodes].empty?
53
53
  lines << "- Collections: #{report[:collection_nodes].map { |node| "#{node[:node_name]} total=#{node[:total]} succeeded=#{node[:succeeded]} failed=#{node[:failed]} status=#{node[:status]}" }.join('; ')}"
@@ -170,6 +170,13 @@ module Igniter
170
170
  "Outputs: #{inline_hash(outputs)}"
171
171
  end
172
172
 
173
+ def presented_outputs
174
+ @presented_outputs ||= execution.compiled_graph.outputs.each_with_object({}) do |output_node, memo|
175
+ raw_value = to_h[:outputs][output_node.name]
176
+ memo[output_node.name] = present_output(output_node.name, raw_value)
177
+ end
178
+ end
179
+
173
180
  def format_nodes(nodes)
174
181
  line = "Nodes: total=#{nodes[:total]}, succeeded=#{nodes[:succeeded]}, failed=#{nodes[:failed]}, stale=#{nodes[:stale]}"
175
182
  line = "Nodes: total=#{nodes[:total]}, succeeded=#{nodes[:succeeded]}, failed=#{nodes[:failed]}, pending=#{nodes[:pending]}, stale=#{nodes[:stale]}"
@@ -203,7 +210,27 @@ module Igniter
203
210
  end
204
211
 
205
212
  def inline_hash(hash)
206
- hash.map { |key, value| "#{key}=#{value.inspect}" }.join(", ")
213
+ hash.map { |key, value| "#{key}=#{inline_value(value)}" }.join(", ")
214
+ end
215
+
216
+ def present_output(output_name, raw_value)
217
+ presenter = execution.contract_instance.class.output_presenters[output_name.to_sym]
218
+ return raw_value unless presenter
219
+
220
+ if presenter.is_a?(Symbol) || presenter.is_a?(String)
221
+ execution.contract_instance.public_send(
222
+ presenter,
223
+ value: raw_value,
224
+ contract: execution.contract_instance,
225
+ execution: execution
226
+ )
227
+ else
228
+ presenter.call(
229
+ value: raw_value,
230
+ contract: execution.contract_instance,
231
+ execution: execution
232
+ )
233
+ end
207
234
  end
208
235
 
209
236
  def serialize_value(value)
@@ -221,6 +248,78 @@ module Igniter
221
248
  end
222
249
  end
223
250
 
251
+ def inline_value(value)
252
+ case value
253
+ when Hash
254
+ return summarize_serialized_collection_hash(value) if serialized_collection_hash?(value)
255
+ return summarize_serialized_collection_items_hash(value) if serialized_collection_items_hash?(value)
256
+
257
+ "{#{value.map { |key, nested| "#{key}: #{inline_value(nested)}" }.join(', ')}}"
258
+ when Array
259
+ "[#{value.map { |item| inline_value(item) }.join(', ')}]"
260
+ when Runtime::Result
261
+ summarize_nested_result(value)
262
+ when Runtime::CollectionResult
263
+ summarize_collection_result(value)
264
+ else
265
+ value.inspect
266
+ end
267
+ end
268
+
269
+ def summarize_nested_result(result)
270
+ outputs = result.to_h.keys
271
+ "{graph=#{result.execution.compiled_graph.name.inspect}, status=#{nested_result_status(result).inspect}, outputs=#{outputs.inspect}}"
272
+ end
273
+
274
+ def summarize_collection_result(result)
275
+ summary = result.summary
276
+ failed_keys = result.failures.keys
277
+ "{mode=#{result.mode.inspect}, total=#{summary[:total]}, succeeded=#{summary[:succeeded]}, failed=#{summary[:failed]}, status=#{summary[:status].inspect}, keys=#{result.keys.inspect}, failed_keys=#{failed_keys.inspect}}"
278
+ end
279
+
280
+ def serialized_collection_hash?(value)
281
+ value.key?(:mode) && value.key?(:summary) && value.key?(:items)
282
+ end
283
+
284
+ def summarize_serialized_collection_hash(value)
285
+ summary = value[:summary] || {}
286
+ items = value[:items] || {}
287
+ failed_keys = items.each_with_object([]) do |(key, item), memo|
288
+ memo << key if item[:status] == :failed || item["status"] == :failed
289
+ end
290
+
291
+ "{mode=#{value[:mode].inspect}, total=#{summary[:total]}, succeeded=#{summary[:succeeded]}, failed=#{summary[:failed]}, status=#{summary[:status].inspect}, keys=#{items.keys.inspect}, failed_keys=#{failed_keys.inspect}}"
292
+ end
293
+
294
+ def serialized_collection_items_hash?(value)
295
+ return false if value.empty?
296
+
297
+ value.values.all? do |item|
298
+ item.is_a?(Hash) && (item.key?(:key) || item.key?("key")) && (item.key?(:status) || item.key?("status"))
299
+ end
300
+ end
301
+
302
+ def summarize_serialized_collection_items_hash(value)
303
+ failed_keys = value.each_with_object([]) do |(key, item), memo|
304
+ status = item[:status] || item["status"]
305
+ memo << key if status == :failed || status == "failed"
306
+ end
307
+
308
+ total = value.size
309
+ failed = failed_keys.size
310
+ succeeded = total - failed
311
+ status = failed.zero? ? :succeeded : :partial_failure
312
+
313
+ "{mode=:collect, total=#{total}, succeeded=#{succeeded}, failed=#{failed}, status=#{status.inspect}, keys=#{value.keys.inspect}, failed_keys=#{failed_keys.inspect}}"
314
+ end
315
+
316
+ def nested_result_status(result)
317
+ return :failed if result.failed?
318
+ return :pending if result.pending?
319
+
320
+ :succeeded
321
+ end
322
+
224
323
  def summarize_collection_nodes
225
324
  execution.cache.values.filter_map do |state|
226
325
  next unless state.value.is_a?(Runtime::CollectionResult)