igniter 0.3.1 → 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 (114) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +238 -218
  4. data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
  5. data/docs/LLM_V1.md +335 -0
  6. data/docs/PATTERNS.md +189 -0
  7. data/docs/SERVER_V1.md +313 -0
  8. data/examples/README.md +129 -0
  9. data/examples/agents.rb +150 -0
  10. data/examples/differential.rb +161 -0
  11. data/examples/distributed_server.rb +94 -0
  12. data/examples/distributed_workflow.rb +52 -0
  13. data/examples/effects.rb +184 -0
  14. data/examples/invariants.rb +179 -0
  15. data/examples/order_pipeline.rb +163 -0
  16. data/examples/provenance.rb +122 -0
  17. data/examples/saga.rb +110 -0
  18. data/lib/igniter/agent/mailbox.rb +96 -0
  19. data/lib/igniter/agent/message.rb +21 -0
  20. data/lib/igniter/agent/ref.rb +86 -0
  21. data/lib/igniter/agent/runner.rb +129 -0
  22. data/lib/igniter/agent/state_holder.rb +23 -0
  23. data/lib/igniter/agent.rb +155 -0
  24. data/lib/igniter/compiler/compiled_graph.rb +12 -0
  25. data/lib/igniter/compiler/validation_pipeline.rb +3 -1
  26. data/lib/igniter/compiler/validators/await_validator.rb +53 -0
  27. data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
  28. data/lib/igniter/compiler/validators/dependencies_validator.rb +41 -1
  29. data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
  30. data/lib/igniter/compiler.rb +2 -0
  31. data/lib/igniter/contract.rb +59 -8
  32. data/lib/igniter/differential/divergence.rb +29 -0
  33. data/lib/igniter/differential/formatter.rb +96 -0
  34. data/lib/igniter/differential/report.rb +86 -0
  35. data/lib/igniter/differential/runner.rb +130 -0
  36. data/lib/igniter/differential.rb +51 -0
  37. data/lib/igniter/dsl/contract_builder.rb +74 -4
  38. data/lib/igniter/effect.rb +91 -0
  39. data/lib/igniter/effect_registry.rb +78 -0
  40. data/lib/igniter/errors.rb +17 -2
  41. data/lib/igniter/execution_report/builder.rb +54 -0
  42. data/lib/igniter/execution_report/formatter.rb +50 -0
  43. data/lib/igniter/execution_report/node_entry.rb +24 -0
  44. data/lib/igniter/execution_report/report.rb +65 -0
  45. data/lib/igniter/execution_report.rb +32 -0
  46. data/lib/igniter/extensions/differential.rb +114 -0
  47. data/lib/igniter/extensions/execution_report.rb +27 -0
  48. data/lib/igniter/extensions/invariants.rb +116 -0
  49. data/lib/igniter/extensions/provenance.rb +45 -0
  50. data/lib/igniter/extensions/saga.rb +74 -0
  51. data/lib/igniter/integrations/agents.rb +18 -0
  52. data/lib/igniter/integrations/llm/config.rb +69 -0
  53. data/lib/igniter/integrations/llm/context.rb +74 -0
  54. data/lib/igniter/integrations/llm/executor.rb +159 -0
  55. data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
  56. data/lib/igniter/integrations/llm/providers/base.rb +33 -0
  57. data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
  58. data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
  59. data/lib/igniter/integrations/llm.rb +59 -0
  60. data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
  61. data/lib/igniter/integrations/rails/contract_job.rb +76 -0
  62. data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
  63. data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
  64. data/lib/igniter/integrations/rails/railtie.rb +25 -0
  65. data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
  66. data/lib/igniter/integrations/rails.rb +12 -0
  67. data/lib/igniter/invariant.rb +50 -0
  68. data/lib/igniter/model/await_node.rb +21 -0
  69. data/lib/igniter/model/effect_node.rb +37 -0
  70. data/lib/igniter/model/remote_node.rb +26 -0
  71. data/lib/igniter/model.rb +3 -0
  72. data/lib/igniter/property_testing/formatter.rb +66 -0
  73. data/lib/igniter/property_testing/generators.rb +115 -0
  74. data/lib/igniter/property_testing/result.rb +45 -0
  75. data/lib/igniter/property_testing/run.rb +43 -0
  76. data/lib/igniter/property_testing/runner.rb +47 -0
  77. data/lib/igniter/property_testing.rb +64 -0
  78. data/lib/igniter/provenance/builder.rb +97 -0
  79. data/lib/igniter/provenance/lineage.rb +82 -0
  80. data/lib/igniter/provenance/node_trace.rb +65 -0
  81. data/lib/igniter/provenance/text_formatter.rb +70 -0
  82. data/lib/igniter/provenance.rb +29 -0
  83. data/lib/igniter/registry.rb +67 -0
  84. data/lib/igniter/runtime/execution.rb +2 -2
  85. data/lib/igniter/runtime/input_validator.rb +5 -3
  86. data/lib/igniter/runtime/resolver.rb +58 -1
  87. data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
  88. data/lib/igniter/runtime/stores/file_store.rb +50 -2
  89. data/lib/igniter/runtime/stores/memory_store.rb +55 -2
  90. data/lib/igniter/runtime/stores/redis_store.rb +13 -1
  91. data/lib/igniter/saga/compensation.rb +31 -0
  92. data/lib/igniter/saga/compensation_record.rb +20 -0
  93. data/lib/igniter/saga/executor.rb +85 -0
  94. data/lib/igniter/saga/formatter.rb +49 -0
  95. data/lib/igniter/saga/result.rb +47 -0
  96. data/lib/igniter/saga.rb +56 -0
  97. data/lib/igniter/server/client.rb +123 -0
  98. data/lib/igniter/server/config.rb +27 -0
  99. data/lib/igniter/server/handlers/base.rb +105 -0
  100. data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
  101. data/lib/igniter/server/handlers/event_handler.rb +28 -0
  102. data/lib/igniter/server/handlers/execute_handler.rb +37 -0
  103. data/lib/igniter/server/handlers/health_handler.rb +32 -0
  104. data/lib/igniter/server/handlers/status_handler.rb +27 -0
  105. data/lib/igniter/server/http_server.rb +109 -0
  106. data/lib/igniter/server/rack_app.rb +35 -0
  107. data/lib/igniter/server/registry.rb +56 -0
  108. data/lib/igniter/server/router.rb +75 -0
  109. data/lib/igniter/server.rb +67 -0
  110. data/lib/igniter/stream_loop.rb +80 -0
  111. data/lib/igniter/supervisor.rb +167 -0
  112. data/lib/igniter/version.rb +1 -1
  113. data/lib/igniter.rb +14 -0
  114. metadata +92 -2
@@ -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
@@ -11,10 +11,10 @@ module Igniter
11
11
  @runner_strategy = runner
12
12
  @max_workers = max_workers
13
13
  @store = store
14
- @input_validator = InputValidator.new(compiled_graph)
14
+ @events = Events::Bus.new
15
+ @input_validator = InputValidator.new(compiled_graph, execution_id: @events.execution_id)
15
16
  @inputs = @input_validator.normalize_initial_inputs(inputs)
16
17
  @cache = Cache.new
17
- @events = Events::Bus.new
18
18
  @audit = Extensions::Auditing::Timeline.new(self)
19
19
  @events.subscribe(@audit)
20
20
  @resolver = Resolver.new(self)
@@ -3,8 +3,9 @@
3
3
  module Igniter
4
4
  module Runtime
5
5
  class InputValidator
6
- def initialize(compiled_graph)
6
+ def initialize(compiled_graph, execution_id: nil)
7
7
  @compiled_graph = compiled_graph
8
+ @execution_id = execution_id
8
9
  end
9
10
 
10
11
  def normalize_initial_inputs(raw_inputs)
@@ -106,7 +107,7 @@ module Igniter
106
107
  hash.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = value }
107
108
  end
108
109
 
109
- def input_error(input_node, message)
110
+ def input_error(input_node, message) # rubocop:disable Metrics/MethodLength
110
111
  InputError.new(
111
112
  message,
112
113
  context: {
@@ -114,7 +115,8 @@ module Igniter
114
115
  node_id: input_node.id,
115
116
  node_name: input_node.name,
116
117
  node_path: input_node.path,
117
- source_location: input_node.source_location
118
+ source_location: input_node.source_location,
119
+ execution_id: @execution_id
118
120
  }
119
121
  )
120
122
  end
@@ -25,6 +25,12 @@ module Igniter
25
25
  resolve_branch(node)
26
26
  when :collection
27
27
  resolve_collection(node)
28
+ when :effect
29
+ resolve_effect(node)
30
+ when :await
31
+ resolve_await(node)
32
+ when :remote
33
+ resolve_remote(node)
28
34
  else
29
35
  raise ResolutionError, "Unsupported node kind: #{node.kind}"
30
36
  end
@@ -59,6 +65,56 @@ module Igniter
59
65
  NodeState.new(node: node, status: :succeeded, value: @execution.fetch_input!(node.name))
60
66
  end
61
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
+
81
+ def resolve_await(node)
82
+ deferred = Runtime::DeferredResult.build(
83
+ payload: { event: node.event_name },
84
+ source_node: node.name,
85
+ waiting_on: node.name
86
+ )
87
+ raise PendingDependencyError.new(deferred, "Waiting for external event '#{node.event_name}'")
88
+ end
89
+
90
+ def resolve_remote(node) # rubocop:disable Metrics/MethodLength
91
+ unless defined?(Igniter::Server::Client)
92
+ raise ResolutionError,
93
+ "remote: nodes require `require 'igniter/server'` (server integration not loaded)"
94
+ end
95
+
96
+ inputs = node.input_mapping.each_with_object({}) do |(child_input, dep_name), memo|
97
+ memo[child_input] = resolve_dependency_value(dep_name)
98
+ end
99
+
100
+ client = Igniter::Server::Client.new(node.node_url, timeout: node.timeout)
101
+ response = client.execute(node.contract_name, inputs: inputs)
102
+
103
+ case response[:status]
104
+ when :succeeded
105
+ NodeState.new(node: node, status: :succeeded, value: response[:outputs])
106
+ when :failed
107
+ error_message = response.dig(:error, :message) || response.dig(:error, "message")
108
+ raise ResolutionError,
109
+ "Remote #{node.contract_name}@#{node.node_url}: #{error_message}"
110
+ else
111
+ raise ResolutionError,
112
+ "Remote #{node.contract_name}@#{node.node_url}: unexpected status '#{response[:status]}'"
113
+ end
114
+ rescue Igniter::Server::Client::ConnectionError => e
115
+ raise ResolutionError, "Cannot reach #{node.node_url}: #{e.message}"
116
+ end
117
+
62
118
  def resolve_compute(node)
63
119
  dependencies = node.dependencies.each_with_object({}) do |dependency_name, memo|
64
120
  memo[dependency_name] = resolve_dependency_value(dependency_name)
@@ -310,7 +366,8 @@ module Igniter
310
366
  node_id: node.id,
311
367
  node_name: node.name,
312
368
  node_path: node.path,
313
- source_location: node.source_location
369
+ source_location: node.source_location,
370
+ execution_id: @execution.events.execution_id
314
371
  }
315
372
  )
316
373
  end
@@ -12,7 +12,7 @@ module Igniter
12
12
  @snapshot_column = snapshot_column.to_sym
13
13
  end
14
14
 
15
- def save(snapshot)
15
+ def save(snapshot, correlation: nil, graph: nil) # rubocop:disable Lint/UnusedMethodArgument
16
16
  execution_id = snapshot[:execution_id] || snapshot["execution_id"]
17
17
  record = @record_class.find_or_initialize_by(@execution_id_column => execution_id)
18
18
  record.public_send(:"#{@snapshot_column}=", JSON.generate(snapshot))
@@ -20,6 +20,18 @@ module Igniter
20
20
  execution_id
21
21
  end
22
22
 
23
+ def find_by_correlation(graph:, correlation:)
24
+ raise NotImplementedError, "find_by_correlation is not implemented for ActiveRecordStore"
25
+ end
26
+
27
+ def list_all(graph: nil)
28
+ raise NotImplementedError, "list_all is not implemented for ActiveRecordStore"
29
+ end
30
+
31
+ def list_pending(graph: nil)
32
+ raise NotImplementedError, "list_pending is not implemented for ActiveRecordStore"
33
+ end
34
+
23
35
  def fetch(execution_id)
24
36
  record = @record_class.find_by(@execution_id_column => execution_id)
25
37
  raise Igniter::ResolutionError, "No execution snapshot found for '#{execution_id}'" unless record
@@ -12,12 +12,51 @@ module Igniter
12
12
  FileUtils.mkdir_p(@root)
13
13
  end
14
14
 
15
- def save(snapshot)
15
+ def save(snapshot, correlation: nil, graph: nil)
16
16
  execution_id = snapshot[:execution_id] || snapshot["execution_id"]
17
- File.write(path_for(execution_id), JSON.pretty_generate(snapshot))
17
+ data = snapshot.merge(
18
+ _graph: graph,
19
+ _correlation: correlation&.transform_keys(&:to_s)
20
+ ).compact
21
+ File.write(path_for(execution_id), JSON.pretty_generate(data))
18
22
  execution_id
19
23
  end
20
24
 
25
+ def find_by_correlation(graph:, correlation:)
26
+ normalized = correlation.transform_keys(&:to_s)
27
+ each_snapshot do |data|
28
+ next unless data["_graph"] == graph
29
+
30
+ stored_corr = data["_correlation"] || {}
31
+ return data["execution_id"] if stored_corr == normalized
32
+ end
33
+ nil
34
+ end
35
+
36
+ def list_all(graph: nil)
37
+ results = []
38
+ each_snapshot do |data|
39
+ next if graph && data["_graph"] != graph
40
+
41
+ results << data["execution_id"]
42
+ end
43
+ results
44
+ end
45
+
46
+ def list_pending(graph: nil)
47
+ results = []
48
+ each_snapshot do |data|
49
+ next if graph && data["_graph"] != graph
50
+
51
+ states = data["states"] || {}
52
+ pending = states.any? do |_name, state|
53
+ (state["status"] || state[:status]).to_s == "pending"
54
+ end
55
+ results << data["execution_id"] if pending
56
+ end
57
+ results
58
+ end
59
+
21
60
  def fetch(execution_id)
22
61
  JSON.parse(File.read(path_for(execution_id)))
23
62
  rescue Errno::ENOENT
@@ -37,6 +76,15 @@ module Igniter
37
76
  def path_for(execution_id)
38
77
  File.join(@root, "#{execution_id}.json")
39
78
  end
79
+
80
+ def each_snapshot(&block)
81
+ Dir.glob(File.join(@root, "*.json")).each do |file|
82
+ data = JSON.parse(File.read(file))
83
+ block.call(data)
84
+ rescue JSON::ParserError
85
+ next
86
+ end
87
+ end
40
88
  end
41
89
  end
42
90
  end
@@ -6,15 +6,68 @@ module Igniter
6
6
  class MemoryStore
7
7
  def initialize
8
8
  @snapshots = {}
9
+ @correlation_index = {}
9
10
  @mutex = Mutex.new
10
11
  end
11
12
 
12
- def save(snapshot)
13
+ def save(snapshot, correlation: nil, graph: nil) # rubocop:disable Metrics/MethodLength
13
14
  execution_id = snapshot[:execution_id] || snapshot["execution_id"]
14
- @mutex.synchronize { @snapshots[execution_id] = deep_copy(snapshot) }
15
+ @mutex.synchronize do
16
+ @snapshots[execution_id] = deep_copy(snapshot)
17
+ if graph
18
+ @correlation_index[execution_id] = {
19
+ graph: graph,
20
+ correlation: (correlation || {}).transform_keys(&:to_sym)
21
+ }
22
+ end
23
+ end
15
24
  execution_id
16
25
  end
17
26
 
27
+ def find_by_correlation(graph:, correlation:)
28
+ normalized = correlation.transform_keys(&:to_sym)
29
+ @mutex.synchronize do
30
+ @correlation_index.each do |execution_id, entry|
31
+ next unless entry[:graph] == graph
32
+ return execution_id if entry[:correlation] == normalized
33
+ end
34
+ nil
35
+ end
36
+ end
37
+
38
+ def list_all(graph: nil)
39
+ @mutex.synchronize do
40
+ if graph
41
+ @correlation_index.select { |_id, entry| entry[:graph] == graph }.keys
42
+ else
43
+ @snapshots.keys
44
+ end
45
+ end
46
+ end
47
+
48
+ def list_pending(graph: nil) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
49
+ ids = @mutex.synchronize do
50
+ if graph
51
+ @correlation_index.select { |_id, entry| entry[:graph] == graph }.keys
52
+ else
53
+ @snapshots.keys
54
+ end
55
+ end
56
+
57
+ @mutex.synchronize do
58
+ ids.select do |id|
59
+ snapshot = @snapshots[id]
60
+ next false unless snapshot
61
+
62
+ states = snapshot[:states] || snapshot["states"] || {}
63
+ states.any? do |_name, state|
64
+ status = state[:status] || state["status"]
65
+ status.to_s == "pending"
66
+ end
67
+ end
68
+ end
69
+ end
70
+
18
71
  def fetch(execution_id)
19
72
  @mutex.synchronize { deep_copy(@snapshots.fetch(execution_id)) }
20
73
  rescue KeyError
@@ -11,12 +11,24 @@ module Igniter
11
11
  @namespace = namespace
12
12
  end
13
13
 
14
- def save(snapshot)
14
+ def save(snapshot, correlation: nil, graph: nil) # rubocop:disable Lint/UnusedMethodArgument
15
15
  execution_id = snapshot[:execution_id] || snapshot["execution_id"]
16
16
  @redis.set(redis_key(execution_id), JSON.generate(snapshot))
17
17
  execution_id
18
18
  end
19
19
 
20
+ def find_by_correlation(graph:, correlation:)
21
+ raise NotImplementedError, "find_by_correlation is not implemented for RedisStore"
22
+ end
23
+
24
+ def list_all(graph: nil)
25
+ raise NotImplementedError, "list_all is not implemented for RedisStore"
26
+ end
27
+
28
+ def list_pending(graph: nil)
29
+ raise NotImplementedError, "list_pending is not implemented for RedisStore"
30
+ end
31
+
20
32
  def fetch(execution_id)
21
33
  payload = @redis.get(redis_key(execution_id))
22
34
  raise Igniter::ResolutionError, "No execution snapshot found for '#{execution_id}'" unless payload
@@ -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
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Saga
5
+ # Immutable result of a saga execution (resolve_saga call).
6
+ #
7
+ # Attributes:
8
+ # contract — the contract instance that was executed
9
+ # error — Igniter::Error that caused failure (nil on success)
10
+ # failed_node — Symbol name of the first node that failed (nil on success)
11
+ # compensations — Array<CompensationRecord> for all attempted compensations
12
+ class Result
13
+ attr_reader :contract, :error, :failed_node, :compensations
14
+
15
+ def initialize(success:, contract:, error: nil, failed_node: nil, compensations: [])
16
+ @success = success
17
+ @contract = contract
18
+ @error = error
19
+ @failed_node = failed_node
20
+ @compensations = compensations.freeze
21
+ freeze
22
+ end
23
+
24
+ def success? = @success
25
+ def failed? = !@success
26
+
27
+ # Human-readable saga report.
28
+ def explain
29
+ Formatter.format(self)
30
+ end
31
+
32
+ alias to_s explain
33
+
34
+ # Structured (serialisable) representation.
35
+ def to_h
36
+ {
37
+ success: success?,
38
+ failed_node: failed_node,
39
+ error: error&.message,
40
+ compensations: compensations.map do |rec|
41
+ { node: rec.node_name, success: rec.success?, error: rec.error&.message }
42
+ end
43
+ }
44
+ end
45
+ end
46
+ end
47
+ end