igniter 0.2.0 → 0.3.1

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 +21 -0
  3. data/README.md +224 -1
  4. data/docs/API_V2.md +296 -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 +127 -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 +269 -0
  17. data/lib/igniter/compiler/compiled_graph.rb +90 -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 +153 -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 +152 -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 +186 -11
  35. data/lib/igniter/dsl/contract_builder.rb +271 -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 +33 -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 +46 -0
  51. data/lib/igniter/model/collection_node.rb +31 -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 +310 -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
@@ -5,16 +5,36 @@ module Igniter
5
5
  class OutputNode < Node
6
6
  attr_reader :source
7
7
 
8
- def initialize(id:, name:, source:, metadata: {})
8
+ def initialize(id:, name:, source:, path: nil, metadata: {})
9
+ normalized_source = source.to_s
10
+
9
11
  super(
10
12
  id: id,
11
13
  kind: :output,
12
14
  name: name,
13
- path: "output.#{name}",
14
- dependencies: [source],
15
+ path: (path || "output.#{name}"),
16
+ dependencies: [normalized_source.split(".").first],
15
17
  metadata: metadata
16
18
  )
17
- @source = source.to_sym
19
+ @source = normalized_source.include?(".") ? normalized_source : normalized_source.to_sym
20
+ end
21
+
22
+ def source_root
23
+ source.to_s.split(".").first.to_sym
24
+ end
25
+
26
+ def composition_output?
27
+ source.to_s.include?(".")
28
+ end
29
+
30
+ def type
31
+ metadata[:type]
32
+ end
33
+
34
+ def child_output_name
35
+ return unless composition_output?
36
+
37
+ source.to_s.split(".", 2).last.to_sym
18
38
  end
19
39
  end
20
40
  end
data/lib/igniter/model.rb CHANGED
@@ -5,6 +5,8 @@ require_relative "model/graph"
5
5
  require_relative "model/input_node"
6
6
  require_relative "model/compute_node"
7
7
  require_relative "model/composition_node"
8
+ require_relative "model/branch_node"
9
+ require_relative "model/collection_node"
8
10
  require_relative "model/output_node"
9
11
 
10
12
  module Igniter
@@ -5,47 +5,86 @@ module Igniter
5
5
  class Cache
6
6
  def initialize
7
7
  @states = {}
8
+ @mutex = Mutex.new
9
+ @condition = ConditionVariable.new
8
10
  end
9
11
 
10
12
  def fetch(node_name)
11
- @states[node_name.to_sym]
13
+ @mutex.synchronize { @states[node_name.to_sym] }
12
14
  end
13
15
 
14
16
  def write(state)
15
- current = fetch(state.node.name)
16
- version = state.version || next_version(current)
17
- @states[state.node.name] = NodeState.new(
18
- node: state.node,
19
- status: state.status,
20
- value: state.value,
21
- error: state.error,
22
- version: version,
23
- resolved_at: state.resolved_at,
24
- invalidated_by: state.invalidated_by
25
- )
17
+ @mutex.synchronize do
18
+ current = @states[state.node.name]
19
+ version = state.version || (current&.running? ? current.version : next_version(current))
20
+ @states[state.node.name] = NodeState.new(
21
+ node: state.node,
22
+ status: state.status,
23
+ value: state.value,
24
+ error: state.error,
25
+ version: version,
26
+ resolved_at: state.resolved_at,
27
+ invalidated_by: state.invalidated_by
28
+ )
29
+ @condition.broadcast
30
+ end
31
+ end
32
+
33
+ def begin_resolution(node)
34
+ @mutex.synchronize do
35
+ loop do
36
+ current = @states[node.name]
37
+ return [:cached, current] if current && !current.stale? && !current.running?
38
+
39
+ unless current&.running?
40
+ @states[node.name] = NodeState.new(
41
+ node: node,
42
+ status: :running,
43
+ value: current&.value,
44
+ error: current&.error,
45
+ version: next_version(current),
46
+ resolved_at: current&.resolved_at || Time.now.utc,
47
+ invalidated_by: nil
48
+ )
49
+ return [:started, @states[node.name]]
50
+ end
51
+
52
+ @condition.wait(@mutex)
53
+ end
54
+ end
26
55
  end
27
56
 
28
57
  def stale!(node, invalidated_by:)
29
- current = fetch(node.name)
30
- return unless current
58
+ @mutex.synchronize do
59
+ current = @states[node.name]
60
+ return unless current
31
61
 
32
- @states[node.name] = NodeState.new(
33
- node: node,
34
- status: :stale,
35
- value: current.value,
36
- error: current.error,
37
- version: current.version + 1,
38
- resolved_at: current.resolved_at,
39
- invalidated_by: invalidated_by
40
- )
62
+ @states[node.name] = NodeState.new(
63
+ node: node,
64
+ status: :stale,
65
+ value: current.value,
66
+ error: current.error,
67
+ version: current.version + 1,
68
+ resolved_at: current.resolved_at,
69
+ invalidated_by: invalidated_by
70
+ )
71
+ @condition.broadcast
72
+ end
41
73
  end
42
74
 
43
75
  def values
44
- @states.values
76
+ @mutex.synchronize { @states.values }
45
77
  end
46
78
 
47
79
  def to_h
48
- @states.dup
80
+ @mutex.synchronize { @states.dup }
81
+ end
82
+
83
+ def restore!(states)
84
+ @mutex.synchronize do
85
+ @states = states.transform_keys(&:to_sym)
86
+ @condition.broadcast
87
+ end
49
88
  end
50
89
 
51
90
  private
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Runtime
5
+ class CollectionResult
6
+ Item = Struct.new(:key, :status, :result, :error, keyword_init: true) do
7
+ def succeeded?
8
+ status == :succeeded
9
+ end
10
+
11
+ def failed?
12
+ status == :failed
13
+ end
14
+
15
+ def to_h
16
+ {
17
+ key: key,
18
+ status: status,
19
+ result: serialize_result(result),
20
+ error: serialize_error(error)
21
+ }.compact
22
+ end
23
+
24
+ private
25
+
26
+ def serialize_result(value)
27
+ case value
28
+ when Runtime::Result
29
+ value.to_h
30
+ else
31
+ value
32
+ end
33
+ end
34
+
35
+ def serialize_error(value)
36
+ return nil unless value
37
+
38
+ {
39
+ type: value.class.name,
40
+ message: value.message,
41
+ context: value.respond_to?(:context) ? value.context : {}
42
+ }
43
+ end
44
+ end
45
+
46
+ attr_reader :items, :mode
47
+
48
+ def initialize(items:, mode:)
49
+ @items = items.freeze
50
+ @mode = mode.to_sym
51
+ end
52
+
53
+ def [](key)
54
+ items.fetch(key)
55
+ end
56
+
57
+ def keys
58
+ items.keys
59
+ end
60
+
61
+ def successes
62
+ items.select { |_key, item| item.succeeded? }
63
+ end
64
+
65
+ def failures
66
+ items.select { |_key, item| item.failed? }
67
+ end
68
+
69
+ def items_summary
70
+ items.transform_values do |item|
71
+ {
72
+ status: item.status,
73
+ error: item.error&.message
74
+ }.compact
75
+ end
76
+ end
77
+
78
+ def failed_items
79
+ failures.transform_values do |item|
80
+ {
81
+ type: item.error.class.name,
82
+ message: item.error.message,
83
+ context: item.error.respond_to?(:context) ? item.error.context : {}
84
+ }
85
+ end
86
+ end
87
+
88
+ def to_h
89
+ items.transform_values(&:to_h)
90
+ end
91
+
92
+ def summary
93
+ {
94
+ mode: mode,
95
+ total: items.size,
96
+ succeeded: successes.size,
97
+ failed: failures.size,
98
+ status: failures.empty? ? :succeeded : :partial_failure
99
+ }
100
+ end
101
+
102
+ def as_json(*)
103
+ {
104
+ mode: mode,
105
+ summary: summary,
106
+ items: to_h
107
+ }
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Igniter
6
+ module Runtime
7
+ class DeferredResult
8
+ attr_reader :token, :payload, :source_node, :waiting_on
9
+
10
+ def initialize(token:, payload: {}, source_node: nil, waiting_on: nil)
11
+ @token = token
12
+ @payload = payload.freeze
13
+ @source_node = source_node&.to_sym
14
+ @waiting_on = waiting_on&.to_sym
15
+ end
16
+
17
+ def self.build(token: nil, payload: {}, source_node: nil, waiting_on: nil)
18
+ new(
19
+ token: token || SecureRandom.uuid,
20
+ payload: payload,
21
+ source_node: source_node,
22
+ waiting_on: waiting_on
23
+ )
24
+ end
25
+
26
+ def to_h
27
+ {
28
+ token: token,
29
+ payload: payload,
30
+ source_node: source_node,
31
+ waiting_on: waiting_on
32
+ }.compact
33
+ end
34
+
35
+ def as_json(*)
36
+ to_h
37
+ end
38
+ end
39
+ end
40
+ end
@@ -3,11 +3,14 @@
3
3
  module Igniter
4
4
  module Runtime
5
5
  class Execution
6
- attr_reader :compiled_graph, :contract_instance, :inputs, :cache, :events, :audit
6
+ attr_reader :compiled_graph, :contract_instance, :inputs, :cache, :events, :audit, :runner_strategy, :max_workers, :store
7
7
 
8
- def initialize(compiled_graph:, contract_instance:, inputs:)
8
+ def initialize(compiled_graph:, contract_instance:, inputs:, runner: :inline, max_workers: nil, store: nil)
9
9
  @compiled_graph = compiled_graph
10
10
  @contract_instance = contract_instance
11
+ @runner_strategy = runner
12
+ @max_workers = max_workers
13
+ @store = store
11
14
  @input_validator = InputValidator.new(compiled_graph)
12
15
  @inputs = @input_validator.normalize_initial_inputs(inputs)
13
16
  @cache = Cache.new
@@ -15,16 +18,16 @@ module Igniter
15
18
  @audit = Extensions::Auditing::Timeline.new(self)
16
19
  @events.subscribe(@audit)
17
20
  @resolver = Resolver.new(self)
21
+ @planner = Planner.new(self)
22
+ @runner = RunnerFactory.build(@runner_strategy, self, resolver: @resolver, max_workers: @max_workers, store: @store)
18
23
  @invalidator = Invalidator.new(self)
19
24
  end
20
25
 
21
26
  def resolve_output(name)
22
27
  output = compiled_graph.fetch_output(name)
23
- with_execution_lifecycle([output.source]) do
24
- state = @resolver.resolve(output.source)
25
- raise state.error if state.failed?
26
-
27
- state.value
28
+ with_execution_lifecycle([output.source_root]) do
29
+ run_targets([output.source_root])
30
+ resolve_exported_output(output)
28
31
  end
29
32
  end
30
33
 
@@ -33,10 +36,11 @@ module Igniter
33
36
  end
34
37
 
35
38
  def resolve_all
36
- output_sources = compiled_graph.outputs.map(&:source)
39
+ output_sources = @planner.targets_for_outputs
37
40
 
38
41
  with_execution_lifecycle(output_sources) do
39
- compiled_graph.outputs.each { |output_node| resolve(output_node.source) }
42
+ run_targets(output_sources)
43
+ compiled_graph.outputs.each { |output_node| resolve_output_value(output_node) }
40
44
  self
41
45
  end
42
46
  end
@@ -55,9 +59,28 @@ module Igniter
55
59
  self
56
60
  end
57
61
 
62
+ def resume(node_name, value:)
63
+ node = compiled_graph.fetch_node(node_name)
64
+ current = cache.fetch(node.name)
65
+ raise ResolutionError, "Node '#{node_name}' is not pending" unless current&.pending?
66
+
67
+ cache.write(NodeState.new(node: node, status: :succeeded, value: value))
68
+ @events.emit(:node_resumed, node: node, status: :succeeded, payload: { resumed: true })
69
+ @invalidator.invalidate_from(node.name)
70
+ persist_runtime_state!
71
+ self
72
+ end
73
+
74
+ def resume_by_token(token, value:)
75
+ node_name = pending_node_name_for_token(token)
76
+ raise ResolutionError, "No pending node found for token '#{token}'" unless node_name
77
+
78
+ resume(node_name, value: value)
79
+ end
80
+
58
81
  def success?
59
82
  resolve_all
60
- !cache.values.any?(&:failed?)
83
+ !failed? && !pending?
61
84
  end
62
85
 
63
86
  def failed?
@@ -65,6 +88,11 @@ module Igniter
65
88
  cache.values.any?(&:failed?)
66
89
  end
67
90
 
91
+ def pending?
92
+ resolve_all
93
+ cache.values.any?(&:pending?)
94
+ end
95
+
68
96
  def states
69
97
  Extensions::Introspection::RuntimeFormatter.states(self)
70
98
  end
@@ -77,13 +105,25 @@ module Igniter
77
105
  Diagnostics::Report.new(self)
78
106
  end
79
107
 
108
+ def plan(output_names = nil)
109
+ @planner.plan(output_names)
110
+ end
111
+
112
+ def explain_plan(output_names = nil)
113
+ Extensions::Introspection::PlanFormatter.to_text(self, output_names)
114
+ end
115
+
80
116
  def to_h
81
117
  {
82
118
  graph: compiled_graph.name,
83
119
  execution_id: events.execution_id,
84
120
  inputs: inputs.dup,
85
- success: !cache.values.any?(&:failed?),
121
+ runner: runner_strategy,
122
+ max_workers: max_workers,
123
+ success: success?,
86
124
  failed: cache.values.any?(&:failed?),
125
+ pending: cache.values.any?(&:pending?),
126
+ plan: plan,
87
127
  states: states,
88
128
  event_count: events.events.size
89
129
  }
@@ -95,6 +135,28 @@ module Igniter
95
135
  )
96
136
  end
97
137
 
138
+ def snapshot(include_resolution: true)
139
+ resolve_pending_safe if include_resolution
140
+
141
+ {
142
+ graph: compiled_graph.name,
143
+ execution_id: events.execution_id,
144
+ runner: runner_strategy,
145
+ max_workers: max_workers,
146
+ inputs: inputs.dup,
147
+ states: serialize_states,
148
+ events: events.events.map(&:as_json)
149
+ }
150
+ end
151
+
152
+ def restore!(snapshot)
153
+ @inputs.replace(symbolize_keys(value_from(snapshot, :inputs) || {}))
154
+ cache.restore!(deserialize_states(value_from(snapshot, :states) || {}))
155
+ events.restore!(events: value_from(snapshot, :events) || [], execution_id: value_from(snapshot, :execution_id))
156
+ audit.restore!(events.events)
157
+ self
158
+ end
159
+
98
160
  private
99
161
 
100
162
  def with_execution_lifecycle(node_names)
@@ -102,6 +164,7 @@ module Igniter
102
164
  @events.emit(:execution_started, payload: { graph: compiled_graph.name, targets: node_names.map(&:to_sym) })
103
165
  begin
104
166
  result = yield
167
+ persist_runtime_state!
105
168
  @events.emit(:execution_finished, payload: { graph: compiled_graph.name, targets: node_names.map(&:to_sym) })
106
169
  result
107
170
  rescue StandardError => e
@@ -114,6 +177,7 @@ module Igniter
114
177
  error: e.message
115
178
  }
116
179
  )
180
+ persist_runtime_state!
117
181
  raise
118
182
  end
119
183
  else
@@ -137,6 +201,192 @@ module Igniter
137
201
  def fetch_input!(name)
138
202
  @input_validator.fetch_value!(name, @inputs)
139
203
  end
204
+
205
+ private
206
+
207
+ def resolve_exported_output(output)
208
+ state = @resolver.resolve(output.source_root)
209
+ raise state.error if state.failed?
210
+ return state.value if state.pending?
211
+
212
+ return state.value unless output.composition_output?
213
+
214
+ state.value.public_send(output.child_output_name)
215
+ end
216
+
217
+ def run_targets(node_names)
218
+ @runner.run(node_names)
219
+ end
220
+
221
+ def persist_runtime_state!
222
+ return unless @runner.respond_to?(:persist!)
223
+
224
+ @runner.persist!
225
+ end
226
+
227
+ def pending_node_name_for_token(token)
228
+ source_match = cache.values.find do |state|
229
+ state.pending? &&
230
+ state.value.is_a?(Runtime::DeferredResult) &&
231
+ state.value.token == token &&
232
+ state.value.source_node == state.node.name
233
+ end
234
+ return source_match.node.name if source_match
235
+
236
+ cache.values.find do |state|
237
+ state.pending? &&
238
+ state.value.is_a?(Runtime::DeferredResult) &&
239
+ state.value.token == token
240
+ end&.node&.name
241
+ end
242
+
243
+ def resolve_pending_safe
244
+ resolve_all
245
+ rescue Igniter::Error
246
+ nil
247
+ end
248
+
249
+ def serialize_states
250
+ cache.to_h.each_with_object({}) do |(node_name, state), memo|
251
+ memo[node_name] = {
252
+ status: state.status,
253
+ version: state.version,
254
+ resolved_at: state.resolved_at&.iso8601,
255
+ invalidated_by: state.invalidated_by,
256
+ value: serialize_state_value(state.value),
257
+ error: serialize_state_error(state.error)
258
+ }
259
+ end
260
+ end
261
+
262
+ def deserialize_states(snapshot_states)
263
+ snapshot_states.each_with_object({}) do |(node_name, state_data), memo|
264
+ node = compiled_graph.fetch_node(node_name)
265
+ memo[node.name] = NodeState.new(
266
+ node: node,
267
+ status: (state_data[:status] || state_data["status"]).to_sym,
268
+ value: deserialize_state_value(node, state_data[:value] || state_data["value"]),
269
+ error: deserialize_state_error(state_data[:error] || state_data["error"]),
270
+ version: state_data[:version] || state_data["version"],
271
+ resolved_at: deserialize_time(state_data[:resolved_at] || state_data["resolved_at"]),
272
+ invalidated_by: (state_data[:invalidated_by] || state_data["invalidated_by"])&.to_sym
273
+ )
274
+ end
275
+ end
276
+
277
+ def serialize_state_value(value)
278
+ case value
279
+ when Runtime::DeferredResult
280
+ { type: :deferred, data: value.as_json }
281
+ when Runtime::Result
282
+ {
283
+ type: :result_snapshot,
284
+ snapshot: value.execution.snapshot(include_resolution: false)
285
+ }
286
+ when Runtime::CollectionResult
287
+ {
288
+ type: :collection_result,
289
+ mode: value.mode,
290
+ items: value.items.transform_values do |item|
291
+ {
292
+ key: item.key,
293
+ status: item.status,
294
+ result: serialize_state_value(item.result),
295
+ error: serialize_state_error(item.error)
296
+ }
297
+ end
298
+ }
299
+ else
300
+ value
301
+ end
302
+ end
303
+
304
+ def deserialize_state_value(node, value)
305
+ if value.is_a?(Hash) && (value[:type] || value["type"])&.to_sym == :deferred
306
+ data = value[:data] || value["data"] || {}
307
+ return Runtime::DeferredResult.build(
308
+ token: data[:token] || data["token"],
309
+ payload: data[:payload] || data["payload"] || {},
310
+ source_node: data[:source_node] || data["source_node"],
311
+ waiting_on: data[:waiting_on] || data["waiting_on"]
312
+ )
313
+ end
314
+
315
+ if value.is_a?(Hash) && (value[:type] || value["type"])&.to_sym == :result_snapshot
316
+ snapshot = value[:snapshot] || value["snapshot"] || {}
317
+ if node.kind == :composition
318
+ child_contract = node.contract_class.restore(snapshot)
319
+ return child_contract.result
320
+ end
321
+
322
+ if node.kind == :branch
323
+ snapshot_graph = snapshot[:graph] || snapshot["graph"]
324
+ contract_class = node.possible_contracts.find { |candidate| candidate.compiled_graph.name == snapshot_graph }
325
+ return value unless contract_class
326
+
327
+ child_contract = contract_class.restore(snapshot)
328
+ return child_contract.result
329
+ end
330
+
331
+ if node.kind == :collection
332
+ child_contract = node.contract_class.restore(snapshot)
333
+ return child_contract.result
334
+ end
335
+ end
336
+
337
+ if value.is_a?(Hash) && (value[:type] || value["type"])&.to_sym == :collection_result
338
+ items = (value[:items] || value["items"] || {}).each_with_object({}) do |(key, item), memo|
339
+ memo[key.is_a?(String) && key.match?(/\A\d+\z/) ? key.to_i : key] = Runtime::CollectionResult::Item.new(
340
+ key: item[:key] || item["key"] || key,
341
+ status: (item[:status] || item["status"]).to_sym,
342
+ result: deserialize_state_value(node, item[:result] || item["result"]),
343
+ error: deserialize_state_error(item[:error] || item["error"])
344
+ )
345
+ end
346
+ return Runtime::CollectionResult.new(
347
+ items: items,
348
+ mode: (value[:mode] || value["mode"] || :collect).to_sym
349
+ )
350
+ end
351
+
352
+ value
353
+ end
354
+
355
+ def serialize_state_error(error)
356
+ return nil unless error
357
+
358
+ {
359
+ type: error.class.name,
360
+ message: error.message,
361
+ context: error.respond_to?(:context) ? error.context : {}
362
+ }
363
+ end
364
+
365
+ def deserialize_state_error(error_data)
366
+ return nil unless error_data
367
+
368
+ ResolutionError.new(
369
+ error_data[:message] || error_data["message"],
370
+ context: error_data[:context] || error_data["context"] || {}
371
+ )
372
+ end
373
+
374
+ def deserialize_time(value)
375
+ case value
376
+ when Time
377
+ value
378
+ when String
379
+ Time.iso8601(value)
380
+ else
381
+ value || Time.now.utc
382
+ end
383
+ end
384
+
385
+ def value_from(data, key)
386
+ data[key] || data[key.to_s]
387
+ end
388
+
389
+ alias_method :resolve_output_value, :resolve_exported_output
140
390
  end
141
391
  end
142
392
  end