igniter 0.4.0 → 0.4.3

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +238 -218
  4. data/docs/LLM_V1.md +335 -0
  5. data/docs/PATTERNS.md +189 -0
  6. data/docs/SERVER_V1.md +313 -0
  7. data/examples/README.md +129 -0
  8. data/examples/agents.rb +150 -0
  9. data/examples/differential.rb +161 -0
  10. data/examples/distributed_server.rb +94 -0
  11. data/examples/effects.rb +184 -0
  12. data/examples/invariants.rb +179 -0
  13. data/examples/order_pipeline.rb +163 -0
  14. data/examples/provenance.rb +122 -0
  15. data/examples/saga.rb +110 -0
  16. data/lib/igniter/agent/mailbox.rb +96 -0
  17. data/lib/igniter/agent/message.rb +21 -0
  18. data/lib/igniter/agent/ref.rb +86 -0
  19. data/lib/igniter/agent/runner.rb +129 -0
  20. data/lib/igniter/agent/state_holder.rb +23 -0
  21. data/lib/igniter/agent.rb +155 -0
  22. data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
  23. data/lib/igniter/differential/divergence.rb +29 -0
  24. data/lib/igniter/differential/formatter.rb +96 -0
  25. data/lib/igniter/differential/report.rb +86 -0
  26. data/lib/igniter/differential/runner.rb +130 -0
  27. data/lib/igniter/differential.rb +51 -0
  28. data/lib/igniter/dsl/contract_builder.rb +32 -0
  29. data/lib/igniter/effect.rb +91 -0
  30. data/lib/igniter/effect_registry.rb +78 -0
  31. data/lib/igniter/errors.rb +11 -1
  32. data/lib/igniter/execution_report/builder.rb +54 -0
  33. data/lib/igniter/execution_report/formatter.rb +50 -0
  34. data/lib/igniter/execution_report/node_entry.rb +24 -0
  35. data/lib/igniter/execution_report/report.rb +65 -0
  36. data/lib/igniter/execution_report.rb +32 -0
  37. data/lib/igniter/extensions/differential.rb +114 -0
  38. data/lib/igniter/extensions/execution_report.rb +27 -0
  39. data/lib/igniter/extensions/invariants.rb +116 -0
  40. data/lib/igniter/extensions/provenance.rb +45 -0
  41. data/lib/igniter/extensions/saga.rb +74 -0
  42. data/lib/igniter/integrations/agents.rb +18 -0
  43. data/lib/igniter/invariant.rb +50 -0
  44. data/lib/igniter/model/effect_node.rb +37 -0
  45. data/lib/igniter/model.rb +1 -0
  46. data/lib/igniter/property_testing/formatter.rb +66 -0
  47. data/lib/igniter/property_testing/generators.rb +115 -0
  48. data/lib/igniter/property_testing/result.rb +45 -0
  49. data/lib/igniter/property_testing/run.rb +43 -0
  50. data/lib/igniter/property_testing/runner.rb +47 -0
  51. data/lib/igniter/property_testing.rb +64 -0
  52. data/lib/igniter/provenance/builder.rb +97 -0
  53. data/lib/igniter/provenance/lineage.rb +82 -0
  54. data/lib/igniter/provenance/node_trace.rb +65 -0
  55. data/lib/igniter/provenance/text_formatter.rb +70 -0
  56. data/lib/igniter/provenance.rb +29 -0
  57. data/lib/igniter/registry.rb +67 -0
  58. data/lib/igniter/runtime/resolver.rb +15 -0
  59. data/lib/igniter/saga/compensation.rb +31 -0
  60. data/lib/igniter/saga/compensation_record.rb +20 -0
  61. data/lib/igniter/saga/executor.rb +85 -0
  62. data/lib/igniter/saga/formatter.rb +49 -0
  63. data/lib/igniter/saga/result.rb +47 -0
  64. data/lib/igniter/saga.rb +56 -0
  65. data/lib/igniter/stream_loop.rb +80 -0
  66. data/lib/igniter/supervisor.rb +167 -0
  67. data/lib/igniter/version.rb +1 -1
  68. data/lib/igniter.rb +10 -0
  69. metadata +57 -1
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require "igniter/extensions/invariants"
5
+ require "igniter/property_testing/generators"
6
+ require "igniter/property_testing/run"
7
+ require "igniter/property_testing/result"
8
+ require "igniter/property_testing/formatter"
9
+ require "igniter/property_testing/runner"
10
+
11
+ module Igniter
12
+ # Property-based testing for Igniter contracts.
13
+ #
14
+ # Generates hundreds of random inputs, runs the contract with each set,
15
+ # and verifies that all declared invariants hold. Violations are collected
16
+ # as data so you can inspect the first counterexample.
17
+ #
18
+ # @example
19
+ # require "igniter/property_testing"
20
+ #
21
+ # G = Igniter::PropertyTesting::Generators
22
+ #
23
+ # class PricingContract < Igniter::Contract
24
+ # define do
25
+ # input :price
26
+ # input :quantity
27
+ # compute :total, depends_on: %i[price quantity] do |price:, quantity:|
28
+ # price * quantity
29
+ # end
30
+ # output :total
31
+ # end
32
+ #
33
+ # invariant(:total_non_negative) { |total:, **| total >= 0 }
34
+ # end
35
+ #
36
+ # result = PricingContract.property_test(
37
+ # generators: { price: G.float(0.0..500.0), quantity: G.positive_integer(max: 100) },
38
+ # runs: 200,
39
+ # seed: 42
40
+ # )
41
+ #
42
+ # puts result.explain
43
+ # puts result.counterexample&.inputs
44
+ #
45
+ module PropertyTesting
46
+ # Class methods added to every Igniter::Contract subclass.
47
+ module ClassMethods
48
+ # Run the contract against randomly generated inputs and verify invariants.
49
+ #
50
+ # Requires at least one `invariant` to be declared on the contract class,
51
+ # though it will still execute and collect execution errors without any.
52
+ #
53
+ # @param generators [Hash{Symbol => #call}] input name → generator callable
54
+ # @param runs [Integer] number of test runs (default: 100)
55
+ # @param seed [Integer, nil] optional RNG seed for reproducibility
56
+ # @return [Igniter::PropertyTesting::Result]
57
+ def property_test(generators:, runs: 100, seed: nil)
58
+ Runner.new(self, generators: generators, runs: runs, seed: seed).run
59
+ end
60
+ end
61
+ end
62
+ end
63
+
64
+ Igniter::Contract.extend(Igniter::PropertyTesting::ClassMethods)
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Provenance
5
+ # Builds a Lineage object for a named output by traversing the compiled
6
+ # graph and reading resolved values from the execution cache.
7
+ #
8
+ # The builder memoises each NodeTrace so that shared dependencies
9
+ # (diamond patterns) point to the same object rather than being duplicated.
10
+ class Builder
11
+ class << self
12
+ # Build lineage for +output_name+ from a resolved +execution+.
13
+ def build(output_name, execution)
14
+ new(execution).build(output_name)
15
+ end
16
+ end
17
+
18
+ def initialize(execution)
19
+ @graph = execution.compiled_graph
20
+ @cache = execution.cache
21
+ end
22
+
23
+ def build(output_name) # rubocop:disable Metrics/MethodLength
24
+ sym = output_name.to_sym
25
+
26
+ raise ProvenanceError, "No output named '#{sym}' in #{@graph.name}" unless @graph.output?(sym)
27
+
28
+ output_node = @graph.fetch_output(sym)
29
+ source_name = output_node.source_root
30
+
31
+ source_node = begin
32
+ @graph.fetch_node(source_name)
33
+ rescue KeyError
34
+ raise ProvenanceError, "Source node '#{source_name}' for output '#{sym}' not found in graph"
35
+ end
36
+
37
+ trace = build_trace(source_node, {})
38
+ Lineage.new(trace)
39
+ end
40
+
41
+ private
42
+
43
+ # Recursively build a NodeTrace for +node+.
44
+ # +memo+ prevents re-processing the same node in diamond-dependency graphs.
45
+ def build_trace(node, memo) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
46
+ return memo[node.name] if memo.key?(node.name)
47
+
48
+ # Reserve the slot to handle (unlikely) circular edge during traversal
49
+ memo[node.name] = nil
50
+
51
+ state = @cache.fetch(node.name)
52
+ value = extract_value(state)
53
+
54
+ contributing = {}
55
+ node.dependencies.each do |dep_name|
56
+ dep_node = safe_fetch_node(dep_name)
57
+ next unless dep_node
58
+
59
+ contributing[dep_name] = build_trace(dep_node, memo)
60
+ end
61
+
62
+ trace = NodeTrace.new(
63
+ name: node.name,
64
+ kind: node.kind,
65
+ value: value,
66
+ contributing: contributing
67
+ )
68
+ memo[node.name] = trace
69
+ trace
70
+ end
71
+
72
+ def safe_fetch_node(name)
73
+ @graph.fetch_node(name)
74
+ rescue KeyError
75
+ nil
76
+ end
77
+
78
+ # Extract a display-friendly value from a NodeState.
79
+ # Composition/Collection results are summarised as hashes.
80
+ def extract_value(state) # rubocop:disable Metrics/MethodLength
81
+ return nil unless state
82
+
83
+ val = state.value
84
+ case val
85
+ when Runtime::Result
86
+ val.to_h
87
+ when Runtime::CollectionResult
88
+ val.summary
89
+ when Runtime::DeferredResult
90
+ { pending: true, event: val.waiting_on }
91
+ else
92
+ val
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Provenance
5
+ # Lineage captures the full provenance of a single contract output.
6
+ #
7
+ # It wraps the NodeTrace tree rooted at the node that produces the output
8
+ # and exposes query methods for understanding what inputs shaped the result.
9
+ #
10
+ # Usage (after `require "igniter/extensions/provenance"`):
11
+ #
12
+ # contract.resolve_all
13
+ # lin = contract.lineage(:grand_total)
14
+ #
15
+ # lin.value # => 229.95
16
+ # lin.contributing_inputs # => { base_price: 100.0, quantity: 2, ... }
17
+ # lin.sensitive_to?(:base_price) # => true
18
+ # lin.sensitive_to?(:user_name) # => false
19
+ # lin.path_to(:base_price) # => [:grand_total, :subtotal, :unit_price, :base_price]
20
+ # puts lin # prints ASCII tree
21
+ #
22
+ class Lineage
23
+ # The NodeTrace rooted at the output's source computation node.
24
+ attr_reader :trace
25
+
26
+ def initialize(trace)
27
+ @trace = trace
28
+ freeze
29
+ end
30
+
31
+ # The output name (same as the root trace node name).
32
+ def output_name
33
+ trace.name
34
+ end
35
+
36
+ # The resolved output value.
37
+ def value
38
+ trace.value
39
+ end
40
+
41
+ # All :input nodes that transitively contributed to this output.
42
+ # Returns Hash{ Symbol => value }.
43
+ def contributing_inputs
44
+ trace.contributing_inputs
45
+ end
46
+
47
+ # Does this output's value depend (transitively) on the given input?
48
+ def sensitive_to?(input_name)
49
+ trace.sensitive_to?(input_name)
50
+ end
51
+
52
+ # Ordered path of node names from the output down to the given input.
53
+ # Returns nil if the input does not contribute to this output.
54
+ def path_to(input_name)
55
+ trace.path_to(input_name)
56
+ end
57
+
58
+ # Human-readable ASCII tree explaining how this output was derived.
59
+ def explain
60
+ TextFormatter.format(trace)
61
+ end
62
+
63
+ alias to_s explain
64
+
65
+ # Structured (serialisable) representation of the full trace tree.
66
+ def to_h
67
+ serialize_trace(trace)
68
+ end
69
+
70
+ private
71
+
72
+ def serialize_trace(trc)
73
+ {
74
+ node: trc.name,
75
+ kind: trc.kind,
76
+ value: trc.value,
77
+ contributing: trc.contributing.transform_values { |dep| serialize_trace(dep) }
78
+ }
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Provenance
5
+ # Immutable snapshot of a single resolved node and its full dependency chain.
6
+ #
7
+ # The tree structure mirrors the contract's dependency graph: each NodeTrace
8
+ # holds the traced values of all the nodes that contributed to its result.
9
+ # Input nodes are leaves (no contributing dependencies).
10
+ #
11
+ # The tree may share nodes (diamond dependencies in the original graph) but
12
+ # each shared node is memoised once by the Builder, so the same NodeTrace
13
+ # object is referenced from multiple parents — it is NOT duplicated.
14
+ class NodeTrace
15
+ attr_reader :name, :kind, :value, :contributing
16
+
17
+ # contributing: Hash{ Symbol => NodeTrace } — may be empty for leaf inputs
18
+ def initialize(name:, kind:, value:, contributing: {})
19
+ @name = name.to_sym
20
+ @kind = kind.to_sym
21
+ @value = value
22
+ @contributing = contributing.freeze
23
+ freeze
24
+ end
25
+
26
+ def input?
27
+ kind == :input
28
+ end
29
+
30
+ # True when this node has no dependencies that contributed to its value.
31
+ def leaf?
32
+ contributing.empty?
33
+ end
34
+
35
+ # Recursively collect all :input nodes that influenced this trace.
36
+ # Returns Hash{ Symbol => value }.
37
+ def contributing_inputs
38
+ return { name => value } if input?
39
+
40
+ contributing.each_value.with_object({}) do |dep, acc|
41
+ acc.merge!(dep.contributing_inputs)
42
+ end
43
+ end
44
+
45
+ # Does this trace transitively depend on the named input?
46
+ def sensitive_to?(input_name)
47
+ contributing_inputs.key?(input_name.to_sym)
48
+ end
49
+
50
+ # Return the ordered path of node names from this node down to the given
51
+ # input, or nil if the input does not contribute to this node.
52
+ def path_to(input_name)
53
+ target = input_name.to_sym
54
+ return [name] if name == target
55
+
56
+ contributing.each_value do |dep|
57
+ sub_path = dep.path_to(target)
58
+ return [name] + sub_path if sub_path
59
+ end
60
+
61
+ nil
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Provenance
5
+ # Renders a NodeTrace tree as a human-readable ASCII tree.
6
+ #
7
+ # Example output:
8
+ #
9
+ # grand_total = 229.95 [compute]
10
+ # ├─ subtotal = 199.96 [compute]
11
+ # │ ├─ unit_price = 99.98 [compute]
12
+ # │ │ └─ base_price = 100.0 [input]
13
+ # │ └─ quantity = 2 [input]
14
+ # └─ shipping_cost = 29.99 [compute]
15
+ # └─ destination = "US" [input]
16
+ #
17
+ module TextFormatter
18
+ VALUE_MAX_LENGTH = 60
19
+
20
+ def self.format(trace)
21
+ lines = []
22
+ render(trace, lines, prefix: "", is_root: true, is_last: true)
23
+ lines.join("\n")
24
+ end
25
+
26
+ # ── private helpers ──────────────────────────────────────────────────────
27
+
28
+ def self.render(trace, lines, prefix:, is_root:, is_last:) # rubocop:disable Metrics/MethodLength
29
+ if is_root
30
+ connector = ""
31
+ child_pad = ""
32
+ elsif is_last
33
+ connector = "└─ "
34
+ child_pad = " "
35
+ else
36
+ connector = "├─ "
37
+ child_pad = "│ "
38
+ end
39
+ child_prefix = prefix + child_pad
40
+
41
+ lines << "#{prefix}#{connector}#{trace.name} = #{format_value(trace.value)} [#{trace.kind}]"
42
+
43
+ deps = trace.contributing.values
44
+ deps.each_with_index do |dep, idx|
45
+ render(dep, lines,
46
+ prefix: child_prefix,
47
+ is_root: false,
48
+ is_last: idx == deps.size - 1)
49
+ end
50
+ end
51
+ private_class_method :render
52
+
53
+ def self.format_value(value) # rubocop:disable Metrics/CyclomaticComplexity
54
+ str = case value
55
+ when nil then "nil"
56
+ when String then value.inspect
57
+ when Symbol then value.inspect
58
+ when Hash then "{#{value.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")}}"
59
+ when Array then "[#{value.map(&:inspect).join(", ")}]"
60
+ else value.inspect
61
+ end
62
+
63
+ return str if str.length <= VALUE_MAX_LENGTH
64
+
65
+ "#{str[0, VALUE_MAX_LENGTH - 3]}..."
66
+ end
67
+ private_class_method :format_value
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "provenance/node_trace"
4
+ require_relative "provenance/text_formatter"
5
+ require_relative "provenance/lineage"
6
+ require_relative "provenance/builder"
7
+
8
+ module Igniter
9
+ # Provenance — runtime data lineage for Igniter contracts.
10
+ #
11
+ # After a contract has been resolved, provenance lets you ask:
12
+ # "How was this output value computed, and which inputs influenced it?"
13
+ #
14
+ # Usage:
15
+ # require "igniter/extensions/provenance"
16
+ #
17
+ # contract.resolve_all
18
+ # lin = contract.lineage(:grand_total)
19
+ #
20
+ # lin.value # => 229.95
21
+ # lin.contributing_inputs # => { base_price: 100.0, quantity: 2 }
22
+ # lin.sensitive_to?(:base_price) # => true
23
+ # lin.path_to(:base_price) # => [:grand_total, :subtotal, :base_price]
24
+ # puts lin # ASCII tree
25
+ #
26
+ module Provenance
27
+ class ProvenanceError < Igniter::Error; end
28
+ end
29
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ # Thread-safe process registry mapping names to agent Refs.
5
+ #
6
+ # Names can be any object that is a valid Hash key (Symbols recommended).
7
+ #
8
+ # Igniter::Registry.register(:counter, ref)
9
+ # Igniter::Registry.find(:counter) # => ref
10
+ # Igniter::Registry.unregister(:counter)
11
+ #
12
+ module Registry
13
+ class RegistryError < Igniter::Error; end
14
+
15
+ @store = {}
16
+ @mutex = Mutex.new
17
+
18
+ class << self
19
+ # Register +ref+ under +name+. Raises RegistryError if the name is taken.
20
+ def register(name, ref)
21
+ @mutex.synchronize do
22
+ raise RegistryError, "Name '#{name}' is already registered" if @store.key?(name)
23
+
24
+ @store[name] = ref
25
+ end
26
+ ref
27
+ end
28
+
29
+ # Register (or replace) +ref+ under +name+ without uniqueness check.
30
+ def register!(name, ref)
31
+ @mutex.synchronize { @store[name] = ref }
32
+ ref
33
+ end
34
+
35
+ # Return the Ref registered under +name+, or nil if not found.
36
+ def find(name)
37
+ @mutex.synchronize { @store[name] }
38
+ end
39
+
40
+ # Return the Ref or raise RegistryError if not found.
41
+ def fetch(name)
42
+ @mutex.synchronize do
43
+ @store.fetch(name) { raise RegistryError, "No agent registered as '#{name}'" }
44
+ end
45
+ end
46
+
47
+ # Remove and return the Ref registered under +name+.
48
+ def unregister(name)
49
+ @mutex.synchronize { @store.delete(name) }
50
+ end
51
+
52
+ def registered?(name)
53
+ @mutex.synchronize { @store.key?(name) }
54
+ end
55
+
56
+ # Snapshot of the current name→Ref map.
57
+ def all
58
+ @mutex.synchronize { @store.dup }
59
+ end
60
+
61
+ # Remove all registrations (useful in tests).
62
+ def clear
63
+ @mutex.synchronize { @store.clear }
64
+ end
65
+ end
66
+ end
67
+ end
@@ -25,6 +25,8 @@ module Igniter
25
25
  resolve_branch(node)
26
26
  when :collection
27
27
  resolve_collection(node)
28
+ when :effect
29
+ resolve_effect(node)
28
30
  when :await
29
31
  resolve_await(node)
30
32
  when :remote
@@ -63,6 +65,19 @@ module Igniter
63
65
  NodeState.new(node: node, status: :succeeded, value: @execution.fetch_input!(node.name))
64
66
  end
65
67
 
68
+ def resolve_effect(node)
69
+ dependencies = node.dependencies.each_with_object({}) do |dep, memo|
70
+ memo[dep] = resolve_dependency_value(dep)
71
+ end
72
+
73
+ value = node.adapter_class.new(
74
+ execution: @execution,
75
+ contract: @execution.contract_instance
76
+ ).call(**dependencies)
77
+
78
+ NodeState.new(node: node, status: :succeeded, value: value)
79
+ end
80
+
66
81
  def resolve_await(node)
67
82
  deferred = Runtime::DeferredResult.build(
68
83
  payload: { event: node.event_name },
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Saga
5
+ # Declares the compensating action for a named compute node.
6
+ #
7
+ # The block is called with keyword arguments:
8
+ # inputs: Hash{ Symbol => value } — dependency values the node consumed
9
+ # value: Object — the value the node produced
10
+ #
11
+ # Example:
12
+ # compensate :charge_card do |inputs:, value:|
13
+ # PaymentService.refund(value[:charge_id])
14
+ # end
15
+ class Compensation
16
+ attr_reader :node_name, :block
17
+
18
+ def initialize(node_name, &block)
19
+ raise ArgumentError, "compensate :#{node_name} requires a block" unless block
20
+
21
+ @node_name = node_name.to_sym
22
+ @block = block
23
+ freeze
24
+ end
25
+
26
+ def run(inputs:, value:)
27
+ block.call(inputs: inputs, value: value)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Saga
5
+ # Immutable record of a single compensation that was attempted.
6
+ class CompensationRecord
7
+ attr_reader :node_name, :error
8
+
9
+ def initialize(node_name:, success:, error: nil)
10
+ @node_name = node_name
11
+ @success = success
12
+ @error = error
13
+ freeze
14
+ end
15
+
16
+ def success? = @success
17
+ def failed? = !@success
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Saga
5
+ # Runs declared compensations for all successfully completed nodes,
6
+ # in reverse topological order.
7
+ #
8
+ # A node is eligible for compensation if:
9
+ # 1. Its state in the cache is `succeeded?`
10
+ # 2. A compensation is declared for it, via one of:
11
+ # a. Contract-level `compensate :node_name do ... end` (takes precedence)
12
+ # b. Built-in compensation defined on the Igniter::Effect adapter class
13
+ #
14
+ # Compensation failures are captured as failed CompensationRecords and
15
+ # do NOT halt the rollback of other nodes.
16
+ class Executor
17
+ def initialize(contract)
18
+ @contract = contract
19
+ @graph = contract.execution.compiled_graph
20
+ @cache = contract.execution.cache
21
+ end
22
+
23
+ # @return [Array<CompensationRecord>]
24
+ def run_compensations
25
+ eligible_nodes_reversed.filter_map do |node|
26
+ compensation = find_compensation(node)
27
+ next unless compensation
28
+
29
+ attempt(compensation, node)
30
+ end
31
+ end
32
+
33
+ # Find the first node whose state is :failed in the cache.
34
+ # @return [Symbol, nil]
35
+ def failed_node_name
36
+ @cache.to_h.find { |_name, state| state.failed? }&.first
37
+ end
38
+
39
+ private
40
+
41
+ # Resolve the compensation to use for a node, if any.
42
+ #
43
+ # Contract-level `compensate :node_name` takes precedence over built-in
44
+ # compensation declared on an Igniter::Effect subclass.
45
+ #
46
+ # @return [Igniter::Saga::Compensation, nil]
47
+ def find_compensation(node)
48
+ declared = @contract.class.compensations
49
+ return declared[node.name] if declared.key?(node.name)
50
+
51
+ return nil unless node.kind == :effect
52
+
53
+ built_in = node.adapter_class.built_in_compensation
54
+ return nil unless built_in
55
+
56
+ Compensation.new(node.name, &built_in)
57
+ end
58
+
59
+ # Nodes that succeeded, in reverse resolution order.
60
+ def eligible_nodes_reversed
61
+ @graph.resolution_order
62
+ .select { |node| @cache.fetch(node.name)&.succeeded? }
63
+ .reverse
64
+ end
65
+
66
+ def attempt(compensation, node)
67
+ inputs = extract_inputs(node)
68
+ value = @cache.fetch(node.name)&.value
69
+
70
+ compensation.run(inputs: inputs, value: value)
71
+ CompensationRecord.new(node_name: compensation.node_name, success: true)
72
+ rescue StandardError => e
73
+ CompensationRecord.new(node_name: compensation.node_name, success: false, error: e)
74
+ end
75
+
76
+ # Gather the resolved values of the node's direct dependencies.
77
+ def extract_inputs(node)
78
+ node.dependencies.each_with_object({}) do |dep_name, acc|
79
+ state = @cache.fetch(dep_name)
80
+ acc[dep_name.to_sym] = state&.value
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Saga
5
+ # Formats a SagaResult as a human-readable text block.
6
+ #
7
+ # Example:
8
+ #
9
+ # Contract: OrderWorkflow
10
+ # Status: FAILED
11
+ # Error: Insufficient funds
12
+ # At node: :charge_card
13
+ #
14
+ # COMPENSATIONS (1):
15
+ # [ok] :reserve_stock
16
+ #
17
+ module Formatter
18
+ class << self
19
+ def format(result)
20
+ lines = []
21
+ lines << "Contract: #{result.contract.class.name}"
22
+ lines << "Status: #{result.success? ? "SUCCESS" : "FAILED"}"
23
+
24
+ if result.failed?
25
+ lines << "Error: #{result.error&.message}"
26
+ lines << "At node: :#{result.failed_node}" if result.failed_node
27
+ end
28
+
29
+ append_compensations(result, lines)
30
+ lines.join("\n")
31
+ end
32
+
33
+ private
34
+
35
+ def append_compensations(result, lines)
36
+ return if result.compensations.empty?
37
+
38
+ lines << ""
39
+ lines << "COMPENSATIONS (#{result.compensations.size}):"
40
+ result.compensations.each do |rec|
41
+ tag = rec.success? ? "[ok] " : "[fail] "
42
+ lines << " #{tag} :#{rec.node_name}"
43
+ lines << " error: #{rec.error.message}" if rec.failed?
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end