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
+ module Igniter
4
+ module Differential
5
+ # Captures a single output that differed between primary and candidate.
6
+ class Divergence
7
+ attr_reader :output_name, :primary_value, :candidate_value, :kind
8
+
9
+ # @param output_name [Symbol]
10
+ # @param primary_value [Object]
11
+ # @param candidate_value [Object]
12
+ # @param kind [Symbol] :value_mismatch | :type_mismatch
13
+ def initialize(output_name:, primary_value:, candidate_value:, kind:)
14
+ @output_name = output_name
15
+ @primary_value = primary_value
16
+ @candidate_value = candidate_value
17
+ @kind = kind
18
+ freeze
19
+ end
20
+
21
+ # Numeric difference (candidate − primary). nil for non-numeric values.
22
+ def delta
23
+ return nil unless primary_value.is_a?(Numeric) && candidate_value.is_a?(Numeric)
24
+
25
+ candidate_value - primary_value
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Differential
5
+ # Renders a Differential::Report as a human-readable text block.
6
+ #
7
+ # Example:
8
+ #
9
+ # Primary: PricingV1
10
+ # Candidate: PricingV2
11
+ # Match: NO
12
+ #
13
+ # DIVERGENCES (1):
14
+ # :tax
15
+ # primary: 15.0
16
+ # candidate: 22.5
17
+ # delta: +7.5
18
+ #
19
+ # CANDIDATE ONLY (1):
20
+ # :discount = 10.0
21
+ #
22
+ module Formatter
23
+ VALUE_MAX = 60
24
+
25
+ class << self
26
+ def format(report) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
27
+ lines = []
28
+ lines << "Primary: #{report.primary_class.name}"
29
+ lines << "Candidate: #{report.candidate_class.name}"
30
+ lines << "Match: #{report.match? ? "YES" : "NO"}"
31
+
32
+ if report.primary_error
33
+ lines << ""
34
+ lines << "PRIMARY ERROR: #{report.primary_error.message}"
35
+ return lines.join("\n")
36
+ end
37
+
38
+ if report.candidate_error
39
+ lines << ""
40
+ lines << "CANDIDATE ERROR: #{report.candidate_error.message}"
41
+ end
42
+
43
+ lines << ""
44
+
45
+ if report.divergences.empty? && report.primary_only.empty? && report.candidate_only.empty?
46
+ lines << "All shared outputs match."
47
+ else
48
+ append_divergences(report, lines)
49
+ append_only_section("CANDIDATE ONLY", report.candidate_only, lines)
50
+ append_only_section("PRIMARY ONLY", report.primary_only, lines)
51
+ end
52
+
53
+ lines.join("\n")
54
+ end
55
+
56
+ private
57
+
58
+ def append_divergences(report, lines) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
59
+ return if report.divergences.empty?
60
+
61
+ lines << "DIVERGENCES (#{report.divergences.size}):"
62
+ report.divergences.each do |div|
63
+ lines << " :#{div.output_name}"
64
+ lines << " primary: #{fmt(div.primary_value)}"
65
+ lines << " candidate: #{fmt(div.candidate_value)}"
66
+ next unless div.delta
67
+
68
+ d = div.delta
69
+ lines << " delta: #{d >= 0 ? "+#{d}" : d}"
70
+ end
71
+ lines << ""
72
+ end
73
+
74
+ def append_only_section(label, hash, lines)
75
+ return if hash.empty?
76
+
77
+ lines << "#{label} (#{hash.size}):"
78
+ hash.each { |name, val| lines << " :#{name} = #{fmt(val)}" }
79
+ lines << ""
80
+ end
81
+
82
+ def fmt(value) # rubocop:disable Metrics/CyclomaticComplexity
83
+ str = case value
84
+ when nil then "nil"
85
+ when String then value.inspect
86
+ when Symbol then value.inspect
87
+ when Hash then "{#{value.map { |k, v| "#{k}: #{v.inspect}" }.join(", ")}}"
88
+ when Array then "[#{value.map(&:inspect).join(", ")}]"
89
+ else value.inspect
90
+ end
91
+ str.length > VALUE_MAX ? "#{str[0, VALUE_MAX - 3]}..." : str
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Differential
5
+ # Structured result of comparing two contract implementations.
6
+ #
7
+ # Attributes:
8
+ # primary_class — the reference contract class
9
+ # candidate_class — the contract being validated against the primary
10
+ # inputs — Hash of inputs used for both executions
11
+ # divergences — Array<Divergence> for outputs that differ in value
12
+ # primary_only — Hash{ Symbol => value } outputs absent in candidate
13
+ # candidate_only — Hash{ Symbol => value } outputs absent in primary
14
+ # primary_error — Igniter::Error raised by primary (usually nil)
15
+ # candidate_error — Igniter::Error raised by candidate (nil on success)
16
+ class Report
17
+ attr_reader :primary_class, :candidate_class, :inputs,
18
+ :divergences, :primary_only, :candidate_only,
19
+ :primary_error, :candidate_error
20
+
21
+ def initialize( # rubocop:disable Metrics/ParameterLists
22
+ primary_class:, candidate_class:, inputs:,
23
+ divergences:, primary_only:, candidate_only:,
24
+ primary_error: nil, candidate_error: nil
25
+ )
26
+ @primary_class = primary_class
27
+ @candidate_class = candidate_class
28
+ @inputs = inputs
29
+ @divergences = divergences.freeze
30
+ @primary_only = primary_only.freeze
31
+ @candidate_only = candidate_only.freeze
32
+ @primary_error = primary_error
33
+ @candidate_error = candidate_error
34
+ freeze
35
+ end
36
+
37
+ # True when candidate produces identical outputs with no errors.
38
+ def match?
39
+ divergences.empty? &&
40
+ primary_only.empty? &&
41
+ candidate_only.empty? &&
42
+ primary_error.nil? &&
43
+ candidate_error.nil?
44
+ end
45
+
46
+ # One-line summary suitable for logging.
47
+ def summary # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
48
+ if match?
49
+ "match"
50
+ else
51
+ parts = []
52
+ parts << "#{divergences.size} value(s) differ" if divergences.any?
53
+ parts << "#{primary_only.size} output(s) only in primary" if primary_only.any?
54
+ parts << "#{candidate_only.size} output(s) only in candidate" if candidate_only.any?
55
+ parts << "candidate error: #{candidate_error.message}" if candidate_error
56
+ parts << "primary error: #{primary_error.message}" if primary_error
57
+ "diverged — #{parts.join(", ")}"
58
+ end
59
+ end
60
+
61
+ # Human-readable ASCII report.
62
+ def explain
63
+ Formatter.format(self)
64
+ end
65
+
66
+ alias to_s explain
67
+
68
+ # Structured (serialisable) representation.
69
+ def to_h # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
70
+ {
71
+ primary: primary_class.name,
72
+ candidate: candidate_class.name,
73
+ match: match?,
74
+ divergences: divergences.map do |d|
75
+ { output: d.output_name, primary: d.primary_value, candidate: d.candidate_value,
76
+ kind: d.kind, delta: d.delta }
77
+ end,
78
+ primary_only: primary_only,
79
+ candidate_only: candidate_only,
80
+ primary_error: primary_error&.message,
81
+ candidate_error: candidate_error&.message
82
+ }
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Differential
5
+ # Executes two contract classes with identical inputs and builds a Report.
6
+ #
7
+ # Uses Thread.current[:igniter_skip_shadow] to prevent recursive shadow
8
+ # execution when a contract with shadow_with is run inside the runner.
9
+ class Runner
10
+ def initialize(primary_class, candidate_class, tolerance: nil)
11
+ @primary_class = primary_class
12
+ @candidate_class = candidate_class
13
+ @tolerance = tolerance
14
+ end
15
+
16
+ # Execute both contracts fresh from +inputs+ and compare outputs.
17
+ def run(inputs)
18
+ primary_exec, primary_error = execute(@primary_class, inputs)
19
+ candidate_exec, candidate_error = execute(@candidate_class, inputs)
20
+
21
+ primary_outputs = primary_exec ? extract_outputs(primary_exec) : {}
22
+ candidate_outputs = candidate_exec ? extract_outputs(candidate_exec) : {}
23
+
24
+ build_report(primary_outputs, candidate_outputs, inputs, primary_error, candidate_error)
25
+ end
26
+
27
+ # Compare using an already-resolved primary execution (avoids re-running
28
+ # the primary contract and its side effects a second time).
29
+ def run_with_primary_execution(primary_execution, inputs)
30
+ primary_outputs = extract_outputs(primary_execution)
31
+ candidate_exec, candidate_error = execute(@candidate_class, inputs)
32
+ candidate_outputs = candidate_exec ? extract_outputs(candidate_exec) : {}
33
+
34
+ build_report(primary_outputs, candidate_outputs, inputs, nil, candidate_error)
35
+ end
36
+
37
+ private
38
+
39
+ # Execute +klass+ with +inputs+, suppressing shadow execution to prevent
40
+ # recursive comparisons. Returns [execution, nil] on success or
41
+ # [nil, error] if the contract raises.
42
+ def execute(klass, inputs)
43
+ Thread.current[:igniter_skip_shadow] = true
44
+ contract = klass.new(inputs)
45
+ contract.resolve_all
46
+ [contract.execution, nil]
47
+ rescue Igniter::Error => e
48
+ [nil, e]
49
+ ensure
50
+ Thread.current[:igniter_skip_shadow] = nil
51
+ end
52
+
53
+ # Read all output values from a resolved execution's cache.
54
+ # Output nodes live in graph.outputs (not graph.nodes).
55
+ # Each output node's source_root (Symbol) names the computation node in cache.
56
+ def extract_outputs(execution)
57
+ graph = execution.compiled_graph
58
+ cache = execution.cache
59
+
60
+ graph.outputs.each_with_object({}) do |node, acc|
61
+ state = cache.fetch(node.source_root)
62
+ acc[node.name] = normalize_value(state&.value)
63
+ end
64
+ end
65
+
66
+ # Flatten Runtime wrapper objects to plain Ruby values so that structural
67
+ # equality works across independently-resolved executions.
68
+ def normalize_value(val)
69
+ case val
70
+ when Runtime::Result then val.to_h
71
+ when Runtime::CollectionResult then val.summary
72
+ when Runtime::DeferredResult then { pending: true, event: val.waiting_on }
73
+ else val
74
+ end
75
+ end
76
+
77
+ def build_report(primary_outputs, candidate_outputs, inputs, primary_error, candidate_error) # rubocop:disable Metrics/MethodLength
78
+ common = primary_outputs.keys & candidate_outputs.keys
79
+ divergences = compare_common(primary_outputs, candidate_outputs, common)
80
+ primary_only = slice_missing(primary_outputs, candidate_outputs)
81
+ candidate_only = slice_missing(candidate_outputs, primary_outputs)
82
+
83
+ Report.new(
84
+ primary_class: @primary_class,
85
+ candidate_class: @candidate_class,
86
+ inputs: inputs,
87
+ divergences: divergences,
88
+ primary_only: primary_only,
89
+ candidate_only: candidate_only,
90
+ primary_error: primary_error,
91
+ candidate_error: candidate_error
92
+ )
93
+ end
94
+
95
+ # Build Divergence objects for keys present in both hashes but with
96
+ # differing values.
97
+ def compare_common(primary, candidate, keys) # rubocop:disable Metrics/MethodLength
98
+ keys.filter_map do |key|
99
+ pval = primary[key]
100
+ cval = candidate[key]
101
+ next if values_match?(pval, cval)
102
+
103
+ Divergence.new(
104
+ output_name: key,
105
+ primary_value: pval,
106
+ candidate_value: cval,
107
+ kind: divergence_kind(pval, cval)
108
+ )
109
+ end
110
+ end
111
+
112
+ # Returns a hash of keys that exist in +source+ but are absent in +other+.
113
+ def slice_missing(source, other)
114
+ (source.keys - other.keys).each_with_object({}) { |k, h| h[k] = source[k] }
115
+ end
116
+
117
+ def values_match?(lhs, rhs)
118
+ return true if lhs == rhs
119
+ return false unless @tolerance
120
+ return false unless lhs.is_a?(Numeric) && rhs.is_a?(Numeric)
121
+
122
+ (lhs - rhs).abs <= @tolerance
123
+ end
124
+
125
+ def divergence_kind(lhs, rhs)
126
+ lhs.instance_of?(rhs.class) ? :value_mismatch : :type_mismatch
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "differential/divergence"
5
+ require_relative "differential/report"
6
+ require_relative "differential/formatter"
7
+ require_relative "differential/runner"
8
+
9
+ module Igniter
10
+ # Differential Execution — compare two contract implementations output-by-output.
11
+ #
12
+ # Usage:
13
+ #
14
+ # require "igniter/extensions/differential"
15
+ #
16
+ # # Standalone comparison
17
+ # report = Igniter::Differential.compare(
18
+ # primary: PricingV1,
19
+ # candidate: PricingV2,
20
+ # inputs: { price: 50.0, quantity: 3 }
21
+ # )
22
+ # puts report.explain
23
+ # puts report.match? # => false
24
+ # puts report.divergences # => [#<Divergence ...>]
25
+ #
26
+ # # Per-instance diff (primary already resolved)
27
+ # contract = PricingV1.new(price: 50.0, quantity: 3)
28
+ # contract.resolve_all
29
+ # report = contract.diff_against(PricingV2)
30
+ #
31
+ # # Shadow mode (runs candidate alongside primary automatically)
32
+ # class PricingContract < Igniter::Contract
33
+ # shadow_with PricingV2, on_divergence: ->(r) { Rails.logger.warn(r.summary) }
34
+ # define { ... }
35
+ # end
36
+ #
37
+ module Differential
38
+ class DifferentialError < Igniter::Error; end
39
+
40
+ # Compare +primary+ and +candidate+ contract classes on the given +inputs+.
41
+ #
42
+ # @param primary [Class<Igniter::Contract>]
43
+ # @param candidate [Class<Igniter::Contract>]
44
+ # @param inputs [Hash]
45
+ # @param tolerance [Numeric, nil] optional allowable numeric difference
46
+ # @return [Report]
47
+ def self.compare(primary:, candidate:, inputs:, tolerance: nil)
48
+ Runner.new(primary, candidate, tolerance: tolerance).run(inputs)
49
+ end
50
+ end
51
+ end
@@ -3,12 +3,13 @@
3
3
  module Igniter
4
4
  module DSL
5
5
  class ContractBuilder
6
- def self.compile(name: "AnonymousContract", &block)
7
- new(name: name).tap { |builder| builder.instance_eval(&block) }.compile
6
+ def self.compile(name: "AnonymousContract", correlation_keys: [], &block)
7
+ new(name: name, correlation_keys: correlation_keys).tap { |builder| builder.instance_eval(&block) }.compile
8
8
  end
9
9
 
10
- def initialize(name:)
10
+ def initialize(name:, correlation_keys: [])
11
11
  @name = name
12
+ @correlation_keys = correlation_keys
12
13
  @nodes = []
13
14
  @sequence = 0
14
15
  @scope_stack = []
@@ -228,8 +229,60 @@ module Igniter
228
229
  )
229
230
  end
230
231
 
232
+ def remote(name, contract:, node:, inputs:, timeout: 30, **metadata) # rubocop:disable Metrics/MethodLength
233
+ raise CompileError, "remote :#{name} requires inputs: Hash" unless inputs.is_a?(Hash)
234
+ raise CompileError, "remote :#{name} requires a contract: name" if contract.nil? || contract.to_s.strip.empty?
235
+ raise CompileError, "remote :#{name} requires a node: URL" if node.nil? || node.to_s.strip.empty?
236
+
237
+ add_node(
238
+ Model::RemoteNode.new(
239
+ id: next_id,
240
+ name: name.to_sym,
241
+ contract_name: contract.to_s,
242
+ node_url: node.to_s,
243
+ input_mapping: inputs,
244
+ timeout: timeout,
245
+ path: scoped_path(name),
246
+ metadata: with_source_location(metadata)
247
+ )
248
+ )
249
+ end
250
+
251
+ def effect(name, uses:, depends_on: nil, with: nil, **metadata)
252
+ adapter_class = resolve_effect_adapter(name, uses)
253
+
254
+ add_node(
255
+ Model::EffectNode.new(
256
+ id: next_id,
257
+ name: name,
258
+ dependencies: normalize_dependencies(depends_on: depends_on, with: with),
259
+ adapter_class: adapter_class,
260
+ path: scoped_path(name),
261
+ metadata: with_source_location(metadata)
262
+ )
263
+ )
264
+ end
265
+
266
+ def await(name, event:, **metadata)
267
+ add_node(
268
+ Model::AwaitNode.new(
269
+ id: next_id,
270
+ name: name.to_sym,
271
+ path: scoped_path(name),
272
+ event_name: event,
273
+ metadata: with_source_location(metadata)
274
+ )
275
+ )
276
+ end
277
+
231
278
  def compile
232
- Compiler::GraphCompiler.call(Model::Graph.new(name: @name, nodes: @nodes))
279
+ Compiler::GraphCompiler.call(
280
+ Model::Graph.new(
281
+ name: @name,
282
+ nodes: @nodes,
283
+ metadata: { correlation_keys: @correlation_keys || [] }
284
+ )
285
+ )
233
286
  end
234
287
 
235
288
  private
@@ -268,6 +321,23 @@ module Igniter
268
321
  Array(dependencies)
269
322
  end
270
323
 
324
+ def resolve_effect_adapter(name, uses)
325
+ case uses
326
+ when Symbol, String
327
+ Igniter.effect_registry.fetch(uses.to_sym).adapter_class
328
+ when Class
329
+ unless uses <= Igniter::Effect
330
+ raise CompileError,
331
+ "effect :#{name} `uses:` must be an Igniter::Effect subclass or a registered effect name"
332
+ end
333
+
334
+ uses
335
+ else
336
+ raise CompileError,
337
+ "effect :#{name} `uses:` must be an Igniter::Effect subclass or a registered effect name"
338
+ end
339
+ end
340
+
271
341
  def build_guard_matcher(matcher_name, matcher_value, dependency)
272
342
  case matcher_name
273
343
  when :eq
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ # Base class for side-effect adapters.
5
+ #
6
+ # An Effect is a first-class node in the computation graph that encapsulates
7
+ # an external interaction — database, HTTP call, cache write, queue publish, etc.
8
+ #
9
+ # Effects are declared in contracts via the `effect` DSL keyword:
10
+ #
11
+ # class UserRepository < Igniter::Effect
12
+ # effect_type :database
13
+ # idempotent false
14
+ #
15
+ # def call(user_id:)
16
+ # { id: user_id, name: DB.find(user_id) }
17
+ # end
18
+ #
19
+ # compensate do |inputs:, value:|
20
+ # DB.delete(value[:id])
21
+ # end
22
+ # end
23
+ #
24
+ # class MyContract < Igniter::Contract
25
+ # define do
26
+ # input :user_id
27
+ # effect :user_data, uses: UserRepository, depends_on: :user_id
28
+ # output :user_data
29
+ # end
30
+ # end
31
+ #
32
+ # Effects participate fully in the graph:
33
+ # - Dependency resolution and topological ordering
34
+ # - Execution reports (shown as `effect:database`)
35
+ # - Saga compensations (built-in or contract-level)
36
+ # - Provenance tracing
37
+ class Effect < Executor
38
+ class << self
39
+ def inherited(subclass)
40
+ super
41
+ subclass.instance_variable_set(:@effect_type, @effect_type)
42
+ subclass.instance_variable_set(:@idempotent, @idempotent || false)
43
+ subclass.instance_variable_set(:@_built_in_compensation, @_built_in_compensation)
44
+ end
45
+
46
+ # Declares the category of side effect (e.g., :database, :http, :cache).
47
+ # Shown in execution reports as `effect:<type>`.
48
+ #
49
+ # @param value [Symbol, nil] — omit to read current value
50
+ # @return [Symbol]
51
+ def effect_type(value = nil)
52
+ return @effect_type || :generic if value.nil?
53
+
54
+ @effect_type = value.to_sym
55
+ end
56
+
57
+ # Marks this effect as idempotent — safe to retry without side effects.
58
+ # Informational metadata; does not change execution behaviour.
59
+ #
60
+ # @param value [Boolean]
61
+ def idempotent(value = true) # rubocop:disable Style/OptionalBooleanParameter
62
+ @idempotent = value
63
+ end
64
+
65
+ # @return [Boolean]
66
+ def idempotent?
67
+ @idempotent || false
68
+ end
69
+
70
+ # Declares a built-in compensating action for this effect.
71
+ #
72
+ # Called automatically during a Saga rollback when this effect succeeded
73
+ # but a downstream node failed. A contract-level `compensate :node_name`
74
+ # block takes precedence over the built-in one.
75
+ #
76
+ # The block receives:
77
+ # inputs: — Hash of the node's dependency values when it ran
78
+ # value: — the value produced by this effect (now being undone)
79
+ def compensate(&block)
80
+ raise ArgumentError, "Effect.compensate requires a block" unless block
81
+
82
+ @_built_in_compensation = block
83
+ end
84
+
85
+ # @return [Proc, nil]
86
+ def built_in_compensation
87
+ @_built_in_compensation
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ # Registry for named effect adapters.
5
+ #
6
+ # Allows registering effects by symbolic keys and resolving them in the DSL:
7
+ #
8
+ # Igniter.register_effect(:users_db, UserRepository)
9
+ #
10
+ # # In a contract:
11
+ # effect :user_data, uses: :users_db, depends_on: :user_id
12
+ #
13
+ # This decouples contracts from concrete adapter classes and enables
14
+ # environment-specific swaps (e.g. mock adapters in tests):
15
+ #
16
+ # # In spec_helper.rb:
17
+ # Igniter.effect_registry.clear
18
+ # Igniter.register_effect(:users_db, FakeUserRepository)
19
+ class EffectRegistry
20
+ Registration = Struct.new(:key, :adapter_class, :metadata, keyword_init: true)
21
+
22
+ def initialize
23
+ @entries = {}
24
+ end
25
+
26
+ # Register an effect adapter under a symbolic key.
27
+ #
28
+ # @param key [Symbol, String]
29
+ # @param adapter_class [Class] must be a subclass of Igniter::Effect
30
+ # @param metadata [Hash] optional arbitrary metadata
31
+ # @return [self]
32
+ def register(key, adapter_class, **metadata)
33
+ key = key.to_sym
34
+ unless adapter_class.is_a?(Class) && adapter_class <= Igniter::Effect
35
+ raise ArgumentError, "#{adapter_class.inspect} must be a subclass of Igniter::Effect"
36
+ end
37
+
38
+ @entries[key] = Registration.new(key: key, adapter_class: adapter_class, metadata: metadata.freeze)
39
+ self
40
+ end
41
+
42
+ # Fetch a registration by key.
43
+ #
44
+ # @param key [Symbol, String]
45
+ # @return [Registration]
46
+ # @raise [KeyError] if not registered
47
+ def fetch(key)
48
+ @entries.fetch(key.to_sym) do
49
+ raise KeyError,
50
+ "Effect '#{key}' is not registered. " \
51
+ "Use Igniter.register_effect(:#{key}, AdapterClass) before compiling."
52
+ end
53
+ end
54
+
55
+ # @param key [Symbol, String]
56
+ # @return [Boolean]
57
+ def registered?(key)
58
+ @entries.key?(key.to_sym)
59
+ end
60
+
61
+ # @return [Array<Registration>]
62
+ def all
63
+ @entries.values.freeze
64
+ end
65
+
66
+ # @return [Integer]
67
+ def size
68
+ @entries.size
69
+ end
70
+
71
+ # Remove all registrations. Useful in tests.
72
+ # @return [self]
73
+ def clear
74
+ @entries.clear
75
+ self
76
+ end
77
+ end
78
+ end