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
@@ -29,18 +29,23 @@ module Igniter
29
29
  context[:source_location]
30
30
  end
31
31
 
32
+ def execution_id
33
+ context[:execution_id]
34
+ end
35
+
32
36
  private
33
37
 
34
- def format_message(message, context)
38
+ def format_message(message, context) # rubocop:disable Metrics/AbcSize
35
39
  details = []
36
40
  details << "graph=#{context[:graph]}" if context[:graph]
37
41
  details << "node=#{context[:node_name]}" if context[:node_name]
38
42
  details << "path=#{context[:node_path]}" if context[:node_path]
43
+ details << "execution=#{context[:execution_id]}" if context[:execution_id]
39
44
  details << "location=#{context[:source_location]}" if context[:source_location]
40
45
 
41
46
  return message if details.empty?
42
47
 
43
- "#{message} [#{details.join(', ')}]"
48
+ "#{message} [#{details.join(", ")}]"
44
49
  end
45
50
  end
46
51
 
@@ -53,6 +58,7 @@ module Igniter
53
58
  class ResolutionError < Error; end
54
59
  class CompositionError < Error; end
55
60
  class BranchSelectionError < Error; end
61
+
56
62
  class PendingDependencyError < Error
57
63
  attr_reader :deferred_result
58
64
 
@@ -61,4 +67,13 @@ module Igniter
61
67
  super(message, context: context)
62
68
  end
63
69
  end
70
+
71
+ class InvariantError < Error
72
+ attr_reader :violations
73
+
74
+ def initialize(message = nil, violations: [], context: {})
75
+ @violations = violations.freeze
76
+ super(message, context: context)
77
+ end
78
+ end
64
79
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module ExecutionReport
5
+ # Builds an ExecutionReport::Report by reading the compiled graph's
6
+ # resolution order and matching each node against the execution cache.
7
+ #
8
+ # Works for both successful and failed executions — nodes that never
9
+ # ran (because a dependency failed) appear with status :pending.
10
+ class Builder
11
+ class << self
12
+ def build(contract)
13
+ new(contract).build
14
+ end
15
+ end
16
+
17
+ def initialize(contract)
18
+ @contract = contract
19
+ @graph = contract.execution.compiled_graph
20
+ @cache = contract.execution.cache
21
+ end
22
+
23
+ def build
24
+ entries = @graph.resolution_order.map { |node| entry_for(node) }
25
+ Report.new(contract_class: @contract.class, entries: entries)
26
+ end
27
+
28
+ private
29
+
30
+ def entry_for(node) # rubocop:disable Metrics/MethodLength
31
+ state = @cache.fetch(node.name)
32
+
33
+ status = if state.nil?
34
+ :pending
35
+ elsif state.succeeded?
36
+ :succeeded
37
+ elsif state.failed?
38
+ :failed
39
+ else
40
+ :pending
41
+ end
42
+
43
+ NodeEntry.new(
44
+ name: node.name,
45
+ kind: node.kind,
46
+ status: status,
47
+ value: state&.value,
48
+ error: state&.error,
49
+ effect_type: node.respond_to?(:effect_type) ? node.effect_type : nil
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module ExecutionReport
5
+ # Formats an ExecutionReport::Report as human-readable text.
6
+ #
7
+ # Example:
8
+ #
9
+ # Contract: OrderWorkflow
10
+ # Success: NO
11
+ #
12
+ # [ok] input :order_id
13
+ # [ok] input :amount
14
+ # [ok] compute :reserve_stock
15
+ # [fail] compute :charge_card
16
+ # error: Insufficient funds
17
+ # [pend] compute :send_confirmation
18
+ #
19
+ module Formatter
20
+ class << self
21
+ def format(report)
22
+ lines = []
23
+ lines << "Contract: #{report.contract_class.name}"
24
+ lines << "Success: #{report.success? ? "YES" : "NO"}"
25
+ lines << ""
26
+
27
+ report.entries.each do |entry|
28
+ append_entry(entry, lines)
29
+ end
30
+
31
+ lines.join("\n")
32
+ end
33
+
34
+ private
35
+
36
+ def append_entry(entry, lines)
37
+ tag = case entry.status
38
+ when :succeeded then "[ok] "
39
+ when :failed then "[fail]"
40
+ else "[pend]"
41
+ end
42
+ kind_label = entry.effect_type ? "effect:#{entry.effect_type}" : entry.kind.to_s
43
+ kind_str = kind_label.ljust(10)
44
+ lines << " #{tag} #{kind_str} :#{entry.name}"
45
+ lines << " error: #{entry.error.message}" if entry.failed? && entry.error
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module ExecutionReport
5
+ # Snapshot of a single node's execution state.
6
+ class NodeEntry
7
+ attr_reader :name, :kind, :status, :value, :error, :effect_type
8
+
9
+ def initialize(name:, kind:, status:, value: nil, error: nil, effect_type: nil) # rubocop:disable Metrics/ParameterLists
10
+ @name = name
11
+ @kind = kind
12
+ @status = status
13
+ @value = value
14
+ @error = error
15
+ @effect_type = effect_type
16
+ freeze
17
+ end
18
+
19
+ def succeeded? = status == :succeeded
20
+ def failed? = status == :failed
21
+ def pending? = !succeeded? && !failed?
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module ExecutionReport
5
+ # Structured post-hoc report of what happened during contract execution.
6
+ #
7
+ # Built from the compiled graph's resolution order + execution cache state.
8
+ # Can be generated any time after resolve_all (including after an error).
9
+ #
10
+ # Attributes:
11
+ # contract_class — the contract class that was executed
12
+ # entries — Array<NodeEntry> in resolution order
13
+ class Report
14
+ attr_reader :contract_class, :entries
15
+
16
+ def initialize(contract_class:, entries:)
17
+ @contract_class = contract_class
18
+ @entries = entries.freeze
19
+ freeze
20
+ end
21
+
22
+ # True when no nodes failed.
23
+ def success?
24
+ entries.none?(&:failed?)
25
+ end
26
+
27
+ # Symbol names of nodes that succeeded.
28
+ def resolved_nodes
29
+ entries.select(&:succeeded?).map(&:name)
30
+ end
31
+
32
+ # Symbol names of nodes that failed.
33
+ def failed_nodes
34
+ entries.select(&:failed?).map(&:name)
35
+ end
36
+
37
+ # Symbol names of nodes that never ran.
38
+ def pending_nodes
39
+ entries.select(&:pending?).map(&:name)
40
+ end
41
+
42
+ # Map of { node_name => error } for failed nodes.
43
+ def errors
44
+ entries.select(&:failed?).each_with_object({}) { |e, h| h[e.name] = e.error }
45
+ end
46
+
47
+ # Human-readable execution report.
48
+ def explain
49
+ Formatter.format(self)
50
+ end
51
+
52
+ alias to_s explain
53
+
54
+ def to_h
55
+ {
56
+ contract: contract_class.name,
57
+ success: success?,
58
+ nodes: entries.map do |e|
59
+ { name: e.name, kind: e.kind, status: e.status, error: e.error&.message }
60
+ end
61
+ }
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "execution_report/node_entry"
5
+ require_relative "execution_report/formatter"
6
+ require_relative "execution_report/report"
7
+ require_relative "execution_report/builder"
8
+
9
+ module Igniter
10
+ # Post-hoc execution report — answers "what ran, what succeeded, what failed?"
11
+ #
12
+ # Reconstructs a structured timeline from the compiled graph's resolution
13
+ # order and the execution cache. Works regardless of whether the contract
14
+ # succeeded or raised an error.
15
+ #
16
+ # Usage:
17
+ #
18
+ # require "igniter/extensions/execution_report"
19
+ #
20
+ # contract = MyContract.new(inputs)
21
+ # contract.resolve_all rescue nil # run regardless of outcome
22
+ #
23
+ # report = contract.execution_report
24
+ # report.success? # => false
25
+ # report.failed_nodes # => [:charge_card]
26
+ # report.pending_nodes # => [:send_confirmation]
27
+ # puts report.explain # formatted table
28
+ #
29
+ module ExecutionReport
30
+ class ExecutionReportError < Igniter::Error; end
31
+ end
32
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require "igniter/differential"
5
+
6
+ module Igniter
7
+ module Extensions
8
+ # Patches Igniter::Contract with:
9
+ # - Class method: shadow_with(candidate, async:, on_divergence:, tolerance:)
10
+ # - Instance method: diff_against(candidate_class, tolerance:)
11
+ # - Automatic shadow execution after resolve_all (when shadow_with is declared)
12
+ #
13
+ # This module is applied globally via:
14
+ # Igniter::Contract.include(Igniter::Extensions::Differential)
15
+ #
16
+ module Differential
17
+ def self.included(base)
18
+ base.extend(ClassMethods)
19
+ base.prepend(InstanceMethods)
20
+ end
21
+
22
+ # ── Class-level DSL ────────────────────────────────────────────────────
23
+
24
+ module ClassMethods
25
+ # Declare a shadow candidate that runs alongside every resolve_all call.
26
+ #
27
+ # @param candidate_class [Class<Igniter::Contract>]
28
+ # @param async [Boolean] run the shadow in a background Thread
29
+ # @param on_divergence [#call, nil] invoked with a Report when outputs differ
30
+ # @param tolerance [Numeric, nil] passed to the differential runner
31
+ def shadow_with(candidate_class, async: false, on_divergence: nil, tolerance: nil)
32
+ @_shadow_candidate = candidate_class
33
+ @_shadow_async = async
34
+ @_shadow_on_divergence = on_divergence
35
+ @_shadow_tolerance = tolerance
36
+ end
37
+
38
+ def shadow_candidate = @_shadow_candidate
39
+ def shadow_async? = @_shadow_async || false
40
+ def shadow_on_divergence = @_shadow_on_divergence
41
+ def shadow_tolerance = @_shadow_tolerance
42
+ end
43
+
44
+ # ── Instance override + new public method ─────────────────────────────
45
+
46
+ module InstanceMethods
47
+ # Intercepts resolve_all to trigger shadow execution when shadow_with
48
+ # has been declared. Uses a thread-local flag to prevent recursive
49
+ # shadow calls when the runner itself invokes resolve_all internally.
50
+ def resolve_all(...)
51
+ result = super
52
+ run_shadow unless Thread.current[:igniter_skip_shadow]
53
+ result
54
+ end
55
+
56
+ # Compare the already-executed primary contract against +candidate_class+.
57
+ # Avoids re-running the primary (reads outputs from the existing cache).
58
+ #
59
+ # Raises DifferentialError if resolve_all has not been called yet.
60
+ #
61
+ # @return [Igniter::Differential::Report]
62
+ def diff_against(candidate_class, tolerance: nil)
63
+ unless execution.cache.values.any?
64
+ raise Igniter::Differential::DifferentialError,
65
+ "Contract has not been executed — call resolve_all first"
66
+ end
67
+
68
+ inputs = extract_inputs_from_execution
69
+ runner = Igniter::Differential::Runner.new(self.class, candidate_class, tolerance: tolerance)
70
+ runner.run_with_primary_execution(execution, inputs)
71
+ end
72
+
73
+ private
74
+
75
+ def run_shadow # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
76
+ candidate = self.class.shadow_candidate
77
+ return unless candidate
78
+
79
+ on_divergence = self.class.shadow_on_divergence
80
+ tolerance = self.class.shadow_tolerance
81
+ inputs = extract_inputs_from_execution
82
+
83
+ task = lambda do
84
+ runner = Igniter::Differential::Runner.new(self.class, candidate, tolerance: tolerance)
85
+ report = runner.run_with_primary_execution(execution, inputs)
86
+ on_divergence.call(report) if on_divergence && !report.match?
87
+ end
88
+
89
+ if self.class.shadow_async?
90
+ Thread.new { task.call }
91
+ else
92
+ task.call
93
+ end
94
+ end
95
+
96
+ # Reads input node values from the resolved cache for re-use in
97
+ # secondary executions (shadow / diff_against).
98
+ def extract_inputs_from_execution
99
+ graph = execution.compiled_graph
100
+ cache = execution.cache
101
+
102
+ graph.nodes.each_with_object({}) do |node, acc|
103
+ next unless node.kind == :input
104
+
105
+ state = cache.fetch(node.name)
106
+ acc[node.name] = state&.value
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+
114
+ Igniter::Contract.include(Igniter::Extensions::Differential)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require "igniter/execution_report"
5
+
6
+ module Igniter
7
+ module Extensions
8
+ # Adds execution_report method to all Igniter contracts.
9
+ #
10
+ # Applied globally via:
11
+ # Igniter::Contract.include(Igniter::Extensions::ExecutionReport)
12
+ #
13
+ module ExecutionReport
14
+ # Build a structured execution report from the current execution state.
15
+ #
16
+ # Can be called after resolve_all succeeds OR after it raises — in both
17
+ # cases the cache contains partial or full execution state.
18
+ #
19
+ # @return [Igniter::ExecutionReport::Report]
20
+ def execution_report
21
+ Igniter::ExecutionReport::Builder.build(self)
22
+ end
23
+ end
24
+ end
25
+ end
26
+
27
+ Igniter::Contract.include(Igniter::Extensions::ExecutionReport)
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require "igniter/invariant"
5
+
6
+ module Igniter
7
+ module Extensions
8
+ # Patches Igniter::Contract with:
9
+ # - Class method: invariant(name) { |output:, **| condition }
10
+ # - Instance method: check_invariants → Array<InvariantViolation>
11
+ # - Automatic post-execution check in resolve_all (raises InvariantError)
12
+ #
13
+ # Invariant blocks receive ONLY the contract's declared output values as
14
+ # keyword args — the stable public interface, independent of internal nodes.
15
+ # Use ** to absorb outputs you don't need.
16
+ #
17
+ # @example
18
+ # class PricingContract < Igniter::Contract
19
+ # define do
20
+ # input :price
21
+ # input :quantity
22
+ # compute :total, depends_on: %i[price quantity] do |price:, quantity:|
23
+ # price * quantity
24
+ # end
25
+ # output :total
26
+ # end
27
+ #
28
+ # invariant(:total_non_negative) { |total:, **| total >= 0 }
29
+ # end
30
+ #
31
+ # This module is applied globally via:
32
+ # Igniter::Contract.include(Igniter::Extensions::Invariants)
33
+ #
34
+ module Invariants
35
+ def self.included(base)
36
+ base.extend(ClassMethods)
37
+ base.prepend(InstanceMethods)
38
+ end
39
+
40
+ # ── Class-level DSL ──────────────────────────────────────────────────────
41
+
42
+ module ClassMethods
43
+ # Declare a condition that must always hold after successful execution.
44
+ #
45
+ # The block receives the contract's declared output values as keyword
46
+ # arguments; use ** to absorb outputs you don't care about. Return a
47
+ # truthy value to indicate the invariant holds, falsy to indicate
48
+ # a violation.
49
+ #
50
+ # @example
51
+ # invariant(:positive_total) { |total:, **| total >= 0 }
52
+ #
53
+ # @param name [Symbol]
54
+ def invariant(name, &block)
55
+ @_invariants ||= {}
56
+ @_invariants[name.to_sym] = Igniter::Invariant.new(name, &block)
57
+ end
58
+
59
+ # @return [Hash{Symbol => Igniter::Invariant}]
60
+ def invariants
61
+ @_invariants || {}
62
+ end
63
+ end
64
+
65
+ # ── Instance override + new public method ───────────────────────────────
66
+
67
+ module InstanceMethods
68
+ # Intercepts resolve_all to run invariant checks after execution.
69
+ # Uses a thread-local flag so that property testing can disable the
70
+ # automatic raise and collect violations as data instead.
71
+ def resolve_all(...)
72
+ result = super
73
+ validate_invariants! unless Thread.current[:igniter_skip_invariants]
74
+ result
75
+ end
76
+
77
+ # Run all invariants without raising. Returns violations as data.
78
+ # Safe to call at any time after execution.
79
+ #
80
+ # @return [Array<Igniter::InvariantViolation>]
81
+ def check_invariants
82
+ return [] if self.class.invariants.empty?
83
+
84
+ resolved = collect_output_values
85
+ self.class.invariants.values.filter_map { |inv| inv.check(resolved) }
86
+ end
87
+
88
+ private
89
+
90
+ def validate_invariants!
91
+ violations = check_invariants
92
+ return if violations.empty?
93
+
94
+ names = violations.map { |v| ":#{v.name}" }.join(", ")
95
+ raise Igniter::InvariantError.new(
96
+ "#{violations.size} invariant(s) violated: #{names}",
97
+ violations: violations,
98
+ context: { contract: self.class.name }
99
+ )
100
+ end
101
+
102
+ # Collect all declared output values from the resolved cache.
103
+ # Keyed by output name (not source node name).
104
+ def collect_output_values
105
+ cache = execution.cache
106
+ execution.compiled_graph.outputs.each_with_object({}) do |output_node, acc|
107
+ state = cache.fetch(output_node.source_root)
108
+ acc[output_node.name] = state.value if state&.succeeded?
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ Igniter::Contract.include(Igniter::Extensions::Invariants)
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../provenance"
4
+
5
+ module Igniter
6
+ module Extensions
7
+ # Adds runtime provenance (data lineage) methods to Igniter::Contract.
8
+ #
9
+ # After requiring this file, every resolved contract gains two new methods:
10
+ #
11
+ # contract.resolve_all
12
+ #
13
+ # # Full Lineage object — query API
14
+ # lin = contract.lineage(:grand_total)
15
+ # lin.contributing_inputs # => { base_price: 100.0, quantity: 2 }
16
+ # lin.sensitive_to?(:base_price) # => true
17
+ # lin.path_to(:base_price) # => [:grand_total, :subtotal, :base_price]
18
+ # lin.to_h # serialisable Hash
19
+ #
20
+ # # Shorthand: human-readable ASCII tree printed to stdout
21
+ # contract.explain(:grand_total)
22
+ #
23
+ module Provenance
24
+ # Return a Lineage object for the named output.
25
+ # Raises ProvenanceError if the output is unknown or the contract has not
26
+ # been executed (resolve_all has not been called).
27
+ def lineage(output_name)
28
+ unless execution
29
+ raise Igniter::Provenance::ProvenanceError,
30
+ "Contract has not been executed — call resolve_all first"
31
+ end
32
+
33
+ Igniter::Provenance::Builder.build(output_name, execution)
34
+ end
35
+
36
+ # Return a human-readable ASCII tree explaining how +output_name+ was derived.
37
+ def explain(output_name)
38
+ lineage(output_name).explain
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ # Patch Igniter::Contract so every contract instance gains the methods.
45
+ Igniter::Contract.include(Igniter::Extensions::Provenance)
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require "igniter/saga"
5
+
6
+ module Igniter
7
+ module Extensions
8
+ # Adds saga/compensating-transaction support to all Igniter contracts.
9
+ #
10
+ # Class-level DSL:
11
+ # compensate :node_name do |inputs:, value:| ... end
12
+ #
13
+ # Instance methods added:
14
+ # resolve_saga → Igniter::Saga::Result
15
+ #
16
+ # Applied globally via:
17
+ # Igniter::Contract.include(Igniter::Extensions::Saga)
18
+ #
19
+ module Saga
20
+ def self.included(base)
21
+ base.extend(ClassMethods)
22
+ end
23
+
24
+ # ── Class-level DSL ────────────────────────────────────────────────────
25
+
26
+ module ClassMethods
27
+ # Declare a compensating action for a compute node.
28
+ #
29
+ # The block is called with:
30
+ # inputs: — Hash of the node's dependency values
31
+ # value: — the value the node produced (now being rolled back)
32
+ #
33
+ # @param node_name [Symbol]
34
+ def compensate(node_name, &block)
35
+ @_compensations ||= {}
36
+ @_compensations[node_name.to_sym] = Igniter::Saga::Compensation.new(node_name, &block)
37
+ end
38
+
39
+ # @return [Hash{ Symbol => Compensation }]
40
+ def compensations
41
+ @_compensations || {}
42
+ end
43
+ end
44
+
45
+ # ── Instance methods ───────────────────────────────────────────────────
46
+
47
+ # Execute the contract and handle compensations on failure.
48
+ #
49
+ # Unlike `resolve_all` (which raises on failure), `resolve_saga`:
50
+ # - Returns a successful Result when execution completes
51
+ # - Catches Igniter::Error, runs compensations, returns a failed Result
52
+ #
53
+ # @return [Igniter::Saga::Result]
54
+ def resolve_saga # rubocop:disable Metrics/MethodLength
55
+ resolve_all
56
+ Igniter::Saga::Result.new(success: true, contract: self)
57
+ rescue Igniter::Error => e
58
+ executor = Igniter::Saga::Executor.new(self)
59
+ compensations_ran = executor.run_compensations
60
+ failed_node = executor.failed_node_name
61
+
62
+ Igniter::Saga::Result.new(
63
+ success: false,
64
+ contract: self,
65
+ error: e,
66
+ failed_node: failed_node,
67
+ compensations: compensations_ran
68
+ )
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ Igniter::Contract.include(Igniter::Extensions::Saga)
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Actor system for Igniter — stateful message-driven agents with supervision.
4
+ #
5
+ # Usage:
6
+ # require "igniter/integrations/agents"
7
+ #
8
+ # Provides:
9
+ # Igniter::Agent — base class for stateful actors
10
+ # Igniter::Supervisor — supervises and restarts child agents
11
+ # Igniter::Registry — thread-safe name → Ref lookup
12
+ # Igniter::StreamLoop — continuous contract-in-a-tick-loop
13
+ #
14
+
15
+ require_relative "../agent"
16
+ require_relative "../supervisor"
17
+ require_relative "../registry"
18
+ require_relative "../stream_loop"