igniter 0.2.0 → 0.3.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +224 -1
  4. data/docs/API_V2.md +238 -1
  5. data/docs/BACKLOG.md +166 -0
  6. data/docs/BRANCHES_V1.md +213 -0
  7. data/docs/COLLECTIONS_V1.md +303 -0
  8. data/docs/EXECUTION_MODEL_V2.md +79 -0
  9. data/docs/PATTERNS.md +222 -0
  10. data/docs/STORE_ADAPTERS.md +126 -0
  11. data/examples/README.md +124 -0
  12. data/examples/async_store.rb +47 -0
  13. data/examples/collection.rb +43 -0
  14. data/examples/collection_partial_failure.rb +50 -0
  15. data/examples/marketing_ergonomics.rb +57 -0
  16. data/examples/ringcentral_routing.rb +278 -0
  17. data/lib/igniter/compiler/compiled_graph.rb +82 -0
  18. data/lib/igniter/compiler/graph_compiler.rb +12 -2
  19. data/lib/igniter/compiler/type_resolver.rb +54 -0
  20. data/lib/igniter/compiler/validation_context.rb +61 -0
  21. data/lib/igniter/compiler/validation_pipeline.rb +30 -0
  22. data/lib/igniter/compiler/validator.rb +1 -187
  23. data/lib/igniter/compiler/validators/callable_validator.rb +107 -0
  24. data/lib/igniter/compiler/validators/dependencies_validator.rb +151 -0
  25. data/lib/igniter/compiler/validators/outputs_validator.rb +66 -0
  26. data/lib/igniter/compiler/validators/type_compatibility_validator.rb +84 -0
  27. data/lib/igniter/compiler/validators/uniqueness_validator.rb +60 -0
  28. data/lib/igniter/compiler.rb +8 -0
  29. data/lib/igniter/contract.rb +136 -4
  30. data/lib/igniter/diagnostics/auditing/report/console_formatter.rb +80 -0
  31. data/lib/igniter/diagnostics/auditing/report/markdown_formatter.rb +22 -0
  32. data/lib/igniter/diagnostics/introspection/formatters/mermaid_formatter.rb +58 -0
  33. data/lib/igniter/diagnostics/introspection/formatters/text_tree_formatter.rb +44 -0
  34. data/lib/igniter/diagnostics/report.rb +84 -8
  35. data/lib/igniter/dsl/contract_builder.rb +208 -5
  36. data/lib/igniter/dsl/schema_builder.rb +73 -0
  37. data/lib/igniter/dsl.rb +1 -0
  38. data/lib/igniter/errors.rb +11 -0
  39. data/lib/igniter/events/bus.rb +5 -0
  40. data/lib/igniter/events/event.rb +29 -0
  41. data/lib/igniter/executor.rb +74 -0
  42. data/lib/igniter/executor_registry.rb +44 -0
  43. data/lib/igniter/extensions/auditing/timeline.rb +4 -0
  44. data/lib/igniter/extensions/introspection/graph_formatter.rb +29 -3
  45. data/lib/igniter/extensions/introspection/plan_formatter.rb +55 -0
  46. data/lib/igniter/extensions/introspection/runtime_formatter.rb +18 -3
  47. data/lib/igniter/extensions/introspection.rb +1 -0
  48. data/lib/igniter/extensions/reactive/engine.rb +49 -2
  49. data/lib/igniter/extensions/reactive/reaction.rb +3 -2
  50. data/lib/igniter/model/branch_node.rb +40 -0
  51. data/lib/igniter/model/collection_node.rb +25 -0
  52. data/lib/igniter/model/composition_node.rb +2 -2
  53. data/lib/igniter/model/compute_node.rb +58 -2
  54. data/lib/igniter/model/input_node.rb +2 -2
  55. data/lib/igniter/model/output_node.rb +24 -4
  56. data/lib/igniter/model.rb +2 -0
  57. data/lib/igniter/runtime/cache.rb +64 -25
  58. data/lib/igniter/runtime/collection_result.rb +111 -0
  59. data/lib/igniter/runtime/deferred_result.rb +40 -0
  60. data/lib/igniter/runtime/execution.rb +261 -11
  61. data/lib/igniter/runtime/input_validator.rb +2 -24
  62. data/lib/igniter/runtime/invalidator.rb +1 -1
  63. data/lib/igniter/runtime/job_worker.rb +18 -0
  64. data/lib/igniter/runtime/node_state.rb +20 -0
  65. data/lib/igniter/runtime/planner.rb +126 -0
  66. data/lib/igniter/runtime/resolver.rb +269 -15
  67. data/lib/igniter/runtime/result.rb +14 -2
  68. data/lib/igniter/runtime/runner_factory.rb +20 -0
  69. data/lib/igniter/runtime/runners/inline_runner.rb +21 -0
  70. data/lib/igniter/runtime/runners/store_runner.rb +29 -0
  71. data/lib/igniter/runtime/runners/thread_pool_runner.rb +37 -0
  72. data/lib/igniter/runtime/stores/active_record_store.rb +41 -0
  73. data/lib/igniter/runtime/stores/file_store.rb +43 -0
  74. data/lib/igniter/runtime/stores/memory_store.rb +40 -0
  75. data/lib/igniter/runtime/stores/redis_store.rb +44 -0
  76. data/lib/igniter/runtime.rb +12 -0
  77. data/lib/igniter/type_system.rb +44 -0
  78. data/lib/igniter/version.rb +1 -1
  79. data/lib/igniter.rb +23 -0
  80. metadata +43 -2
@@ -7,16 +7,102 @@ module Igniter
7
7
  @compiled_graph = DSL::ContractBuilder.compile(name: contract_name, &block)
8
8
  end
9
9
 
10
- def react_to(event_type, path: nil, &block)
10
+ def run_with(runner:, max_workers: nil)
11
+ @execution_options = { runner: runner, max_workers: max_workers }.compact
12
+ end
13
+
14
+ def restore_from_store(execution_id, store: nil)
15
+ snapshot = (store || Igniter.execution_store).fetch(execution_id)
16
+ restore(snapshot)
17
+ end
18
+
19
+ def resume_from_store(execution_id, token:, value:, store: nil)
20
+ Runtime::JobWorker.new(self, store: store || Igniter.execution_store).resume(
21
+ execution_id: execution_id,
22
+ token: token,
23
+ value: value
24
+ )
25
+ end
26
+
27
+ def define_schema(schema)
28
+ @compiled_graph = DSL::SchemaBuilder.compile(schema, name: contract_name)
29
+ end
30
+
31
+ def restore(snapshot)
32
+ instance = new(
33
+ snapshot[:inputs] || snapshot["inputs"] || {},
34
+ runner: snapshot[:runner] || snapshot["runner"],
35
+ max_workers: snapshot[:max_workers] || snapshot["max_workers"]
36
+ )
37
+ instance.restore_execution(snapshot)
38
+ end
39
+
40
+ def react_to(event_type, path: nil, once_per_execution: false, &block)
11
41
  raise CompileError, "react_to requires a block" unless block
12
42
 
13
43
  reactions << Extensions::Reactive::Reaction.new(
14
44
  event_type: event_type,
15
45
  path: path,
16
- action: block
46
+ action: block,
47
+ once_per_execution: once_per_execution
17
48
  )
18
49
  end
19
50
 
51
+ def effect(path = nil, event_type: :node_succeeded, &block)
52
+ react_to(event_type, path: path, &block)
53
+ end
54
+
55
+ def on_success(target, &block)
56
+ graph = compiled_graph
57
+ if graph&.output?(target)
58
+ react_to(:execution_finished, once_per_execution: true) do |event:, contract:, execution:|
59
+ next if execution.cache.values.any?(&:failed?)
60
+ next if execution.cache.values.any?(&:pending?)
61
+
62
+ block.call(
63
+ event: event,
64
+ contract: contract,
65
+ execution: execution,
66
+ value: contract.result.public_send(target)
67
+ )
68
+ end
69
+ else
70
+ effect(target.to_s, event_type: :node_succeeded, &block)
71
+ end
72
+ end
73
+
74
+ def on_failure(&block)
75
+ react_to(:execution_failed, once_per_execution: true) do |event:, contract:, execution:|
76
+ block.call(
77
+ event: event,
78
+ contract: contract,
79
+ execution: execution,
80
+ status: :failed,
81
+ errors: terminal_errors(execution),
82
+ error: terminal_errors(execution).values.first
83
+ )
84
+ end
85
+ end
86
+
87
+ def on_exit(&block)
88
+ terminal_hook = proc do |event:, contract:, execution:|
89
+ status = event.type == :execution_failed ? :failed : :succeeded
90
+ errors = status == :failed ? terminal_errors(execution) : {}
91
+
92
+ block.call(
93
+ event: event,
94
+ contract: contract,
95
+ execution: execution,
96
+ status: status,
97
+ errors: errors,
98
+ error: errors.values.first
99
+ )
100
+ end
101
+
102
+ react_to(:execution_finished, once_per_execution: true, &terminal_hook)
103
+ react_to(:execution_failed, once_per_execution: true, &terminal_hook)
104
+ end
105
+
20
106
  def compiled_graph
21
107
  @compiled_graph || superclass_compiled_graph
22
108
  end
@@ -26,6 +112,10 @@ module Igniter
26
112
  @reactions ||= []
27
113
  end
28
114
 
115
+ def execution_options
116
+ @execution_options || superclass_execution_options || {}
117
+ end
118
+
29
119
  private
30
120
 
31
121
  def contract_name
@@ -37,18 +127,47 @@ module Igniter
37
127
 
38
128
  superclass.compiled_graph
39
129
  end
130
+
131
+ def superclass_execution_options
132
+ return unless superclass.respond_to?(:execution_options)
133
+
134
+ superclass.execution_options
135
+ end
136
+
137
+ def terminal_errors(execution)
138
+ execution.cache.values.each_with_object({}) do |state, memo|
139
+ next unless state.failed?
140
+
141
+ memo[state.node.name] = state.error
142
+ end
143
+ end
40
144
  end
41
145
 
42
146
  attr_reader :execution, :result
43
147
 
44
- def initialize(inputs = {})
148
+ def initialize(inputs = nil, runner: nil, max_workers: nil, **keyword_inputs)
45
149
  graph = self.class.compiled_graph
46
150
  raise CompileError, "#{self.class.name} has no compiled graph. Use `define`." unless graph
47
151
 
152
+ normalized_inputs =
153
+ if inputs.nil?
154
+ keyword_inputs
155
+ elsif keyword_inputs.empty?
156
+ inputs
157
+ else
158
+ inputs.to_h.merge(keyword_inputs)
159
+ end
160
+
161
+ execution_options = self.class.execution_options.merge(
162
+ { runner: runner, max_workers: max_workers }.compact
163
+ )
164
+ execution_options[:store] ||= Igniter.execution_store if execution_options[:runner]&.to_sym == :store
165
+
48
166
  @execution = Runtime::Execution.new(
49
167
  compiled_graph: graph,
50
168
  contract_instance: self,
51
- inputs: inputs
169
+ inputs: normalized_inputs,
170
+ **execution_options
52
171
  )
53
172
  @reactive = Extensions::Reactive::Engine.new(
54
173
  execution: @execution,
@@ -98,6 +217,15 @@ module Igniter
98
217
  Diagnostics::Report.new(execution)
99
218
  end
100
219
 
220
+ def snapshot
221
+ execution.snapshot
222
+ end
223
+
224
+ def restore_execution(snapshot)
225
+ execution.restore!(snapshot)
226
+ self
227
+ end
228
+
101
229
  def diagnostics_text
102
230
  diagnostics.to_text
103
231
  end
@@ -106,6 +234,10 @@ module Igniter
106
234
  diagnostics.to_markdown
107
235
  end
108
236
 
237
+ def explain_plan(output_names = nil)
238
+ execution.explain_plan(output_names)
239
+ end
240
+
109
241
  def success?
110
242
  execution.success?
111
243
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Igniter
6
+ module Diagnostics
7
+ module Auditing
8
+ module Report
9
+ # Generates a human-readable text tree of an audition step.
10
+ class ConsoleFormatter
11
+ def self.call(player)
12
+ new(player).call
13
+ end
14
+
15
+ def initialize(player)
16
+ @player = player
17
+ @buffer = StringIO.new
18
+ @definition_graph = player.definition_graph
19
+ @audited_graph = player.current_graph
20
+ end
21
+
22
+ def call
23
+ print_header
24
+
25
+ @definition_graph.root_nodes.each do |node_def|
26
+ build_node_tree(node_def, prefix: "")
27
+ end
28
+
29
+ @buffer.string
30
+ end
31
+
32
+ private
33
+
34
+ def print_header
35
+ trigger = @audited_graph.triggering_event
36
+ @buffer.puts "--- Audition for #{@player.record.contract_class_name} ---"
37
+ @buffer.puts "Step: #{@audited_graph.version} / #{@player.versions_count}"
38
+ @buffer.puts "Trigger: #{trigger[:type]} on '#{trigger[:path]}'" if trigger
39
+ @buffer.puts "-------------------------------------------------"
40
+ end
41
+
42
+ def build_node_tree(node_def, prefix:)
43
+ children_defs = @definition_graph.children_of(node_def)
44
+ is_last = (node_def.parent ? @definition_graph.children_of(node_def.parent) : @definition_graph.root_nodes).last == node_def
45
+
46
+ @buffer << prefix
47
+ @buffer << (is_last ? "└── " : "├── ")
48
+ @buffer << "#{node_def.name} [#{node_def.class.name.split('::').last}]"
49
+
50
+ append_node_details(node_def)
51
+
52
+ @buffer << "\n"
53
+
54
+ new_prefix = prefix + (is_last ? " " : "│ ")
55
+ children_defs.each do |child_def|
56
+ build_node_tree(child_def, prefix: new_prefix)
57
+ end
58
+ end
59
+
60
+ def append_node_details(node_def)
61
+ audited_node = @audited_graph.find(node_def.path)
62
+ if audited_node
63
+ status_icon = case audited_node.status.to_sym
64
+ when :success then "✓"
65
+ when :failure then "✗"
66
+ when :invalidated then "~"
67
+ else "?"
68
+ end
69
+ @buffer << " [#{status_icon}]"
70
+ @buffer << " => <#{audited_node.value_class}> #{audited_node.value.to_s.truncate(100)}"
71
+ @buffer << " (Errors: #{audited_node.errors.join(', ')})" if audited_node.status.to_sym == :failure
72
+ else
73
+ @buffer << " [Not Present in History]"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Diagnostics
5
+ module Auditing
6
+ module Report
7
+ class MarkdownFormatter
8
+ def self.call(player)
9
+ new(player).call
10
+ end
11
+
12
+ def initialize(player)
13
+ @player = player
14
+ @definition_graph = player.definition_graph
15
+ @audited_graph = player.current_graph
16
+ end
17
+
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Diagnostics
5
+ module Introspection
6
+ module Formatters
7
+ class MermaidFormatter
8
+ def self.call(structure)
9
+ new(structure).call
10
+ end
11
+
12
+ def initialize(structure)
13
+ @structure = structure
14
+ @output = StringIO.new
15
+ end
16
+
17
+ def call
18
+ # Устанавливаем направление диаграммы слева направо
19
+ @output.puts "graph LR"
20
+ @output.puts "classDef default fill:#fff,stroke:#333,stroke-width:2px;"
21
+
22
+ # Запускаем рекурсивную отрисовку
23
+ draw_nodes_and_links(@structure)
24
+
25
+ @output.string
26
+ end
27
+
28
+ private
29
+
30
+ def draw_nodes_and_links(nodes)
31
+ nodes.each do |node|
32
+ # 1. Если узел - это Namespace, создаем для него subgraph
33
+ if node[:type] == "Namespace"
34
+ @output.puts " subgraph #{node[:name]}"
35
+ # Рекурсивно отрисовываем всех детей внутри подграфа
36
+ draw_nodes_and_links(node[:children])
37
+ @output.puts " end"
38
+ else
39
+ # 2. Если это обычный узел, просто объявляем его
40
+ node_text = "\"#{node[:name]}<br>[#{node[:details]}]\""
41
+ @output.puts " #{node[:path]}(#{node_text})"
42
+
43
+ # У обычного узла тоже могут быть дети (например, проекции у композиции),
44
+ # их тоже нужно отрисовать.
45
+ draw_nodes_and_links(node[:children])
46
+ end
47
+
48
+ # 3. После объявления узла (или целого подграфа) рисуем его зависимости
49
+ node[:dependencies].each do |dep_path|
50
+ @output.puts " #{dep_path} --> #{node[:path]}"
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Diagnostics
5
+ module Introspection
6
+ module Formatters
7
+ class TextTreeFormatter
8
+ def self.call(structure)
9
+ new(structure).call
10
+ end
11
+
12
+ def initialize(structure)
13
+ @structure = structure
14
+ @output = StringIO.new
15
+ end
16
+
17
+ def call
18
+ @structure.each_with_index do |node, index|
19
+ is_last = index == @structure.size - 1
20
+ print_node(node, "", is_last)
21
+ end
22
+ @output.string
23
+ end
24
+
25
+ private
26
+
27
+ def print_node(node, prefix, is_last)
28
+ connector = is_last ? "└── " : "├── "
29
+ @output << "#{prefix}#{connector}#{node[:name]} [#{node[:type]}] #{node[:details]}\n"
30
+
31
+ children_prefix = prefix + (is_last ? " " : "│ ")
32
+ if node[:dependencies].any?
33
+ @output << "#{children_prefix} └─ depends_on: [#{node[:dependencies].join(', ')}]\n"
34
+ end
35
+
36
+ node[:children].each_with_index do |child, index|
37
+ print_node(child, children_prefix, index == node[:children].size - 1)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -17,6 +17,7 @@ module Igniter
17
17
  outputs: serialize_outputs,
18
18
  errors: serialize_errors,
19
19
  nodes: summarize_nodes,
20
+ collection_nodes: summarize_collection_nodes,
20
21
  events: summarize_events
21
22
  }
22
23
  end
@@ -33,6 +34,7 @@ module Igniter
33
34
  lines << "Status: #{report[:status]}"
34
35
  lines << format_outputs(report[:outputs])
35
36
  lines << format_nodes(report[:nodes])
37
+ lines << format_collection_nodes(report[:collection_nodes])
36
38
  lines << format_errors(report[:errors])
37
39
  lines << format_events(report[:events])
38
40
  lines.compact.join("\n")
@@ -47,6 +49,9 @@ module Igniter
47
49
  lines << "- Status: `#{report[:status]}`"
48
50
  lines << "- Outputs: #{inline_hash(report[:outputs])}"
49
51
  lines << "- Nodes: total=#{report[:nodes][:total]}, succeeded=#{report[:nodes][:succeeded]}, failed=#{report[:nodes][:failed]}, stale=#{report[:nodes][:stale]}"
52
+ unless report[:collection_nodes].empty?
53
+ lines << "- Collections: #{report[:collection_nodes].map { |node| "#{node[:node_name]} total=#{node[:total]} succeeded=#{node[:succeeded]} failed=#{node[:failed]} status=#{node[:status]}" }.join('; ')}"
54
+ end
50
55
  lines << "- Events: total=#{report[:events][:total]}, latest=#{report[:events][:latest_type] || 'none'}"
51
56
 
52
57
  unless report[:errors].empty?
@@ -57,6 +62,17 @@ module Igniter
57
62
  end
58
63
  end
59
64
 
65
+ unless report[:collection_nodes].empty?
66
+ lines << ""
67
+ lines << "## Collections"
68
+ report[:collection_nodes].each do |node|
69
+ lines << "- `#{node[:node_name]}`: total=#{node[:total]}, succeeded=#{node[:succeeded]}, failed=#{node[:failed]}, status=#{node[:status]}"
70
+ node[:failed_items].each do |item|
71
+ lines << "- `#{node[:node_name]}[#{item[:key]}]` failed: #{item[:message]}"
72
+ end
73
+ end
74
+ end
75
+
60
76
  lines.join("\n")
61
77
  end
62
78
 
@@ -76,36 +92,52 @@ module Igniter
76
92
 
77
93
  def status
78
94
  return :failed if execution.cache.values.any?(&:failed?)
95
+ return :pending if execution.cache.values.any?(&:pending?)
79
96
  return :stale if execution.cache.values.any?(&:stale?)
80
97
 
81
98
  :succeeded
82
99
  end
83
100
 
84
101
  def serialize_errors
85
- execution_result.errors.map do |node_name, error|
102
+ execution.cache.values.filter_map do |state|
103
+ next unless state.failed?
104
+
86
105
  {
87
- node_name: node_name,
88
- type: error.class.name,
89
- message: error.message,
90
- context: error.respond_to?(:context) ? error.context : {}
106
+ node_name: state.node.name,
107
+ type: state.error.class.name,
108
+ message: state.error.message,
109
+ context: state.error.respond_to?(:context) ? state.error.context : {}
91
110
  }
92
111
  end
93
112
  end
94
113
 
95
114
  def serialize_outputs
96
115
  execution.compiled_graph.outputs.each_with_object({}) do |output_node, memo|
97
- state = execution.cache.fetch(output_node.source)
98
- memo[output_node.name] = serialize_output_state(state)
116
+ state = execution.cache.fetch(output_node.source_root)
117
+ memo[output_node.name] = serialize_output_value(output_node, state)
99
118
  end
100
119
  end
101
120
 
102
- def serialize_output_state(state)
121
+ def serialize_output_value(output_node, state)
103
122
  return nil unless state
104
123
  return { error: state.error.message, status: state.status } if state.failed?
105
124
 
125
+ if output_node.composition_output?
126
+ return serialize_output_from_child(output_node, state.value)
127
+ end
128
+
106
129
  serialize_value(state.value)
107
130
  end
108
131
 
132
+ def serialize_output_from_child(output_node, child_result)
133
+ return nil unless child_result.is_a?(Runtime::Result)
134
+
135
+ child_errors = child_result.execution.cache.values.select(&:failed?)
136
+ return { error: child_errors.first.error.message, status: :failed } unless child_errors.empty?
137
+
138
+ child_result.public_send(output_node.child_output_name)
139
+ end
140
+
109
141
  def summarize_nodes
110
142
  states = execution.states
111
143
 
@@ -113,6 +145,7 @@ module Igniter
113
145
  total: states.size,
114
146
  succeeded: states.values.count { |state| state[:status] == :succeeded },
115
147
  failed: states.values.count { |state| state[:status] == :failed },
148
+ pending: states.values.count { |state| state[:status] == :pending },
116
149
  stale: states.values.count { |state| state[:status] == :stale },
117
150
  failed_nodes: states.filter_map do |node_name, state|
118
151
  next unless state[:status] == :failed
@@ -139,6 +172,7 @@ module Igniter
139
172
 
140
173
  def format_nodes(nodes)
141
174
  line = "Nodes: total=#{nodes[:total]}, succeeded=#{nodes[:succeeded]}, failed=#{nodes[:failed]}, stale=#{nodes[:stale]}"
175
+ line = "Nodes: total=#{nodes[:total]}, succeeded=#{nodes[:succeeded]}, failed=#{nodes[:failed]}, pending=#{nodes[:pending]}, stale=#{nodes[:stale]}"
142
176
  return line if nodes[:failed_nodes].empty?
143
177
 
144
178
  failures = nodes[:failed_nodes].map { |node| "#{node[:node_name]}(#{node[:error]})" }.join(", ")
@@ -151,6 +185,19 @@ module Igniter
151
185
  "Errors: #{errors.map { |error| "#{error[:node_name]}=#{error[:type]}" }.join(', ')}"
152
186
  end
153
187
 
188
+ def format_collection_nodes(collection_nodes)
189
+ return nil if collection_nodes.empty?
190
+
191
+ summaries = collection_nodes.map do |node|
192
+ summary = "#{node[:node_name]} total=#{node[:total]} succeeded=#{node[:succeeded]} failed=#{node[:failed]} status=#{node[:status]}"
193
+ next summary if node[:failed_items].empty?
194
+
195
+ "#{summary} failed_items=#{node[:failed_items].map { |item| "#{item[:key]}(#{item[:message]})" }.join(', ')}"
196
+ end
197
+
198
+ "Collections: #{summaries.join('; ')}"
199
+ end
200
+
154
201
  def format_events(events)
155
202
  "Events: total=#{events[:total]}, latest=#{events[:latest_type] || 'none'}"
156
203
  end
@@ -161,14 +208,43 @@ module Igniter
161
208
 
162
209
  def serialize_value(value)
163
210
  case value
211
+ when Runtime::DeferredResult
212
+ value.as_json
164
213
  when Runtime::Result
165
214
  value.to_h
215
+ when Runtime::CollectionResult
216
+ value.as_json
166
217
  when Array
167
218
  value.map { |item| serialize_value(item) }
168
219
  else
169
220
  value
170
221
  end
171
222
  end
223
+
224
+ def summarize_collection_nodes
225
+ execution.cache.values.filter_map do |state|
226
+ next unless state.value.is_a?(Runtime::CollectionResult)
227
+
228
+ result = state.value
229
+ {
230
+ node_name: state.node.name,
231
+ path: state.node.path,
232
+ mode: result.mode,
233
+ total: result.items.size,
234
+ succeeded: result.successes.size,
235
+ failed: result.failures.size,
236
+ status: result.failures.empty? ? :succeeded : :partial_failure,
237
+ failed_items: result.failures.values.map do |item|
238
+ {
239
+ key: item.key,
240
+ type: item.error.class.name,
241
+ message: item.error.message,
242
+ context: item.error.respond_to?(:context) ? item.error.context : {}
243
+ }
244
+ end
245
+ }
246
+ end
247
+ end
172
248
  end
173
249
  end
174
250
  end