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
@@ -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
@@ -3,17 +3,6 @@
3
3
  module Igniter
4
4
  module Runtime
5
5
  class InputValidator
6
- SUPPORTED_TYPES = {
7
- integer: Integer,
8
- float: Float,
9
- numeric: Numeric,
10
- string: String,
11
- boolean: :boolean,
12
- array: Array,
13
- hash: Hash,
14
- symbol: Symbol
15
- }.freeze
16
-
17
6
  def initialize(compiled_graph)
18
7
  @compiled_graph = compiled_graph
19
8
  end
@@ -104,26 +93,15 @@ module Igniter
104
93
  return if value.nil?
105
94
  return unless input_node.type
106
95
 
107
- unless supported_type?(input_node.type)
96
+ unless TypeSystem.supported?(input_node.type)
108
97
  raise input_error(input_node, "Unsupported input type '#{input_node.type}' for '#{input_node.name}'")
109
98
  end
110
99
 
111
- return if type_match?(input_node.type, value)
100
+ return if TypeSystem.match?(input_node.type, value)
112
101
 
113
102
  raise input_error(input_node, "Input '#{input_node.name}' must be of type #{input_node.type}, got #{value.class}")
114
103
  end
115
104
 
116
- def supported_type?(type)
117
- SUPPORTED_TYPES.key?(type.to_sym)
118
- end
119
-
120
- def type_match?(type, value)
121
- matcher = SUPPORTED_TYPES.fetch(type.to_sym)
122
- return value == true || value == false if matcher == :boolean
123
-
124
- value.is_a?(matcher)
125
- end
126
-
127
105
  def symbolize_keys(hash)
128
106
  hash.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = value }
129
107
  end
@@ -37,7 +37,7 @@ module Igniter
37
37
 
38
38
  def emit_output_invalidations_for(source_name, cause_name)
39
39
  @execution.compiled_graph.outputs.each do |output_node|
40
- next unless output_node.source == source_name.to_sym
40
+ next unless output_node.source_root == source_name.to_sym
41
41
 
42
42
  @execution.events.emit(
43
43
  :node_invalidated,
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Runtime
5
+ class JobWorker
6
+ def initialize(contract_class, store: Igniter.execution_store)
7
+ @contract_class = contract_class
8
+ @store = store
9
+ end
10
+
11
+ def resume(execution_id:, token:, value:)
12
+ contract = @contract_class.restore_from_store(execution_id, store: @store)
13
+ contract.execution.resume_by_token(token, value: value)
14
+ contract
15
+ end
16
+ end
17
+ end
18
+ end
@@ -19,6 +19,14 @@ module Igniter
19
19
  status == :stale
20
20
  end
21
21
 
22
+ def pending?
23
+ status == :pending
24
+ end
25
+
26
+ def running?
27
+ status == :running
28
+ end
29
+
22
30
  def succeeded?
23
31
  status == :succeeded
24
32
  end
@@ -26,6 +34,18 @@ module Igniter
26
34
  def failed?
27
35
  status == :failed
28
36
  end
37
+
38
+ def to_h
39
+ {
40
+ node_name: node.name,
41
+ status: status,
42
+ version: version,
43
+ resolved_at: resolved_at,
44
+ invalidated_by: invalidated_by,
45
+ value: value,
46
+ error: error
47
+ }
48
+ end
29
49
  end
30
50
  end
31
51
  end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Runtime
5
+ class Planner
6
+ def initialize(execution)
7
+ @execution = execution
8
+ end
9
+
10
+ def targets_for_outputs(output_names = nil)
11
+ selected_outputs = if output_names
12
+ Array(output_names).map { |name| @execution.compiled_graph.fetch_output(name) }
13
+ else
14
+ @execution.compiled_graph.outputs
15
+ end
16
+
17
+ selected_outputs
18
+ .map(&:source_root)
19
+ .uniq
20
+ end
21
+
22
+ def plan(output_names = nil)
23
+ targets = targets_for_outputs(output_names)
24
+ nodes = relevant_nodes_for(targets)
25
+
26
+ node_entries = nodes.each_with_object({}) do |node, memo|
27
+ memo[node.name] = plan_entry(node)
28
+ end
29
+
30
+ {
31
+ targets: targets,
32
+ ready: node_entries.values.select { |entry| entry[:ready] }.map { |entry| entry[:name] },
33
+ blocked: node_entries.values.select { |entry| entry[:blocked] }.map { |entry| entry[:name] },
34
+ nodes: node_entries
35
+ }
36
+ end
37
+
38
+ private
39
+
40
+ def relevant_nodes_for(targets)
41
+ seen = {}
42
+ ordered = []
43
+
44
+ targets.each do |target_name|
45
+ visit(@execution.compiled_graph.fetch_node(target_name), seen, ordered)
46
+ end
47
+
48
+ ordered
49
+ end
50
+
51
+ def visit(node, seen, ordered)
52
+ return if seen[node.name]
53
+
54
+ seen[node.name] = true
55
+ node.dependencies.each do |dependency_name|
56
+ dependency = dependency_node_for(dependency_name)
57
+ visit(dependency, seen, ordered)
58
+ end
59
+ ordered << node
60
+ end
61
+
62
+ def dependency_node_for(dependency_name)
63
+ dependency = @execution.compiled_graph.fetch_dependency(dependency_name)
64
+ return dependency if dependency.kind != :output
65
+
66
+ @execution.compiled_graph.fetch_node(dependency.source_root)
67
+ end
68
+
69
+ def plan_entry(node)
70
+ state = @execution.cache.fetch(node.name)
71
+ dependency_entries = node.dependencies.map { |dependency_name| dependency_entry(dependency_name) }
72
+ blocked_dependencies = dependency_entries.reject { |entry| entry[:satisfied] }.map { |entry| entry[:name] }
73
+ ready = resolution_required?(state) && blocked_dependencies.empty?
74
+
75
+ {
76
+ id: node.id,
77
+ name: node.name,
78
+ path: node.path,
79
+ kind: node.kind,
80
+ status: state&.status || :pending,
81
+ ready: ready,
82
+ blocked: !ready && resolution_required?(state),
83
+ dependencies: dependency_entries,
84
+ waiting_on: blocked_dependencies
85
+ }
86
+ end
87
+
88
+ def dependency_entry(dependency_name)
89
+ dependency = @execution.compiled_graph.fetch_dependency(dependency_name)
90
+ source_node = dependency.kind == :output ? @execution.compiled_graph.fetch_node(dependency.source_root) : dependency
91
+ state = @execution.cache.fetch(source_node.name)
92
+
93
+ {
94
+ name: dependency_name.to_sym,
95
+ source: source_node.name,
96
+ kind: dependency.kind,
97
+ status: state&.status || inferred_status(source_node),
98
+ satisfied: dependency_satisfied?(source_node, state)
99
+ }
100
+ end
101
+
102
+ def dependency_satisfied?(node, state)
103
+ case node.kind
104
+ when :input
105
+ input_available?(node)
106
+ else
107
+ state&.succeeded?
108
+ end
109
+ end
110
+
111
+ def inferred_status(node)
112
+ return :ready if node.kind == :input && input_available?(node)
113
+
114
+ :pending
115
+ end
116
+
117
+ def input_available?(node)
118
+ @execution.inputs.key?(node.name) || node.default?
119
+ end
120
+
121
+ def resolution_required?(state)
122
+ state.nil? || state.stale? || state.pending? || state.running?
123
+ end
124
+ end
125
+ end
126
+ end