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,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ class Agent
5
+ # Runs an agent's message loop in a dedicated Ruby Thread.
6
+ #
7
+ # Responsibilities:
8
+ # - Pop messages from the Mailbox and dispatch to registered handlers
9
+ # - Fire scheduled timers when their interval elapses
10
+ # - Apply handler return-value semantics to StateHolder
11
+ # - Invoke the on_crash callback when the thread dies unexpectedly
12
+ # - Fire lifecycle hooks (after_start, after_crash, after_stop)
13
+ #
14
+ # Handler return-value semantics:
15
+ # Hash → replace agent state with the returned hash
16
+ # :stop → close the mailbox and exit the loop cleanly
17
+ # nil → leave state unchanged (no reply sent to sync caller)
18
+ # other → leave state unchanged; if message has reply_to, send value as reply
19
+ class Runner
20
+ def initialize(agent_class:, mailbox:, state_holder:, on_crash: nil)
21
+ @agent_class = agent_class
22
+ @mailbox = mailbox
23
+ @state_holder = state_holder
24
+ @on_crash = on_crash
25
+ @thread = nil
26
+ @timers = build_timers
27
+ end
28
+
29
+ # Start the message loop in a background thread. Returns the Thread.
30
+ def start
31
+ @thread = Thread.new { run_loop }
32
+ @thread.abort_on_exception = false
33
+ @thread
34
+ end
35
+
36
+ attr_reader :thread
37
+
38
+ private
39
+
40
+ def run_loop # rubocop:disable Metrics/MethodLength
41
+ fire_hooks(:start)
42
+ loop do
43
+ delay = nearest_timer_delay
44
+ message = @mailbox.pop(timeout: delay)
45
+ fire_due_timers
46
+ break if message.nil? && @mailbox.closed?
47
+
48
+ dispatch(message) if message
49
+ end
50
+ rescue StandardError => e
51
+ @on_crash&.call(e)
52
+ fire_hooks(:crash, error: e)
53
+ ensure
54
+ fire_hooks(:stop)
55
+ end
56
+
57
+ def dispatch(message) # rubocop:disable Metrics/MethodLength
58
+ handler = @agent_class.handlers[message.type]
59
+
60
+ unless handler
61
+ # Unknown message type — send nil reply if caller is waiting
62
+ send_reply(message, nil)
63
+ return
64
+ end
65
+
66
+ state = @state_holder.get
67
+ result = handler.call(state: state, payload: message.payload)
68
+
69
+ case result
70
+ when Hash
71
+ @state_holder.set(result)
72
+ send_reply(message, nil)
73
+ when :stop
74
+ send_reply(message, nil)
75
+ @mailbox.close
76
+ when nil
77
+ send_reply(message, nil)
78
+ else
79
+ # Non-state return value — treat as sync reply payload
80
+ send_reply(message, result)
81
+ end
82
+ end
83
+
84
+ def send_reply(message, value)
85
+ return unless message.reply_to
86
+
87
+ message.reply_to.push(
88
+ Message.new(type: :reply, payload: { value: value })
89
+ )
90
+ end
91
+
92
+ # Returns seconds until the next timer fires, or nil if no timers.
93
+ def nearest_timer_delay
94
+ return nil if @timers.empty?
95
+
96
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
97
+ next_at = @timers.map { |t| t[:next_at] }.min
98
+ [next_at - now, 0].max
99
+ end
100
+
101
+ def fire_due_timers
102
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
103
+ @timers.each do |timer|
104
+ next if timer[:next_at] > now
105
+
106
+ state = @state_holder.get
107
+ result = timer[:handler].call(state: state)
108
+ @state_holder.set(result) if result.is_a?(Hash)
109
+ timer[:next_at] = now + timer[:interval]
110
+ end
111
+ end
112
+
113
+ def build_timers
114
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
115
+ @agent_class.timers.map do |t|
116
+ t.merge(next_at: now + t[:interval])
117
+ end
118
+ end
119
+
120
+ def fire_hooks(type, **args)
121
+ @agent_class.hooks[type]&.each do |hook|
122
+ hook.call(**args)
123
+ rescue StandardError
124
+ nil # hooks must not crash the runner
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ class Agent
5
+ # Mutex-guarded wrapper around an agent's current state hash.
6
+ # The state is always stored as a frozen Hash so callers can read it
7
+ # without holding the lock.
8
+ class StateHolder
9
+ def initialize(initial_state)
10
+ @state = initial_state.freeze
11
+ @mutex = Mutex.new
12
+ end
13
+
14
+ def get
15
+ @mutex.synchronize { @state }
16
+ end
17
+
18
+ def set(new_state)
19
+ @mutex.synchronize { @state = new_state.freeze }
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "agent/message"
4
+ require_relative "agent/mailbox"
5
+ require_relative "agent/state_holder"
6
+ require_relative "agent/runner"
7
+ require_relative "agent/ref"
8
+
9
+ module Igniter
10
+ # Base class for stateful, message-driven actors.
11
+ #
12
+ # Subclass Agent and use the class-level DSL to declare:
13
+ # - `initial_state` — default state hash for new instances
14
+ # - `on` — handler for a named message type
15
+ # - `schedule` — recurring timer handler
16
+ # - `mailbox_size` — queue capacity (default: 256)
17
+ # - `mailbox_overflow` — policy when queue is full (:block/:drop_oldest/
18
+ # :drop_newest/:error, default: :block)
19
+ # - `after_start` — hook called when the agent thread begins
20
+ # - `after_crash` — hook called with the error when the thread crashes
21
+ # - `after_stop` — hook called when the agent thread exits
22
+ #
23
+ # Handler return-value semantics:
24
+ # Hash → new state (replaces current state)
25
+ # :stop → shut down the agent cleanly
26
+ # nil → unchanged state
27
+ # other → unchanged state; sent as reply to sync `call()` callers
28
+ #
29
+ # Example:
30
+ #
31
+ # class CounterAgent < Igniter::Agent
32
+ # initial_state counter: 0
33
+ #
34
+ # on :increment do |state:, payload:, **|
35
+ # state.merge(counter: state[:counter] + payload.fetch(:by, 1))
36
+ # end
37
+ #
38
+ # on :count do |state:, **|
39
+ # state[:counter] # returned as sync reply
40
+ # end
41
+ # end
42
+ #
43
+ # ref = CounterAgent.start
44
+ # ref.send(:increment, by: 5)
45
+ # ref.call(:count) # => 5
46
+ # ref.stop
47
+ #
48
+ class Agent
49
+ # ── Custom error types ────────────────────────────────────────────────────
50
+
51
+ class MailboxFullError < Igniter::Error; end
52
+ class TimeoutError < Igniter::Error; end
53
+
54
+ # ── Class-level defaults ──────────────────────────────────────────────────
55
+
56
+ @handlers = {}
57
+ @timers = []
58
+ @default_state = {}
59
+ @mailbox_capacity = Mailbox::DEFAULT_CAPACITY
60
+ @mailbox_overflow = :block
61
+ @hooks = { start: [], crash: [], stop: [] }
62
+
63
+ class << self
64
+ attr_reader :handlers, :timers, :mailbox_capacity, :hooks
65
+
66
+ # ── Inheritance ─────────────────────────────────────────────────────────
67
+
68
+ def inherited(subclass)
69
+ super
70
+ subclass.instance_variable_set(:@handlers, {})
71
+ subclass.instance_variable_set(:@timers, [])
72
+ subclass.instance_variable_set(:@default_state, {})
73
+ subclass.instance_variable_set(:@mailbox_capacity, Mailbox::DEFAULT_CAPACITY)
74
+ subclass.instance_variable_set(:@mailbox_overflow, :block)
75
+ subclass.instance_variable_set(:@hooks, { start: [], crash: [], stop: [] })
76
+ end
77
+
78
+ # ── DSL ─────────────────────────────────────────────────────────────────
79
+
80
+ # Set the initial state hash. Pass a plain Hash or a block returning one.
81
+ def initial_state(hash = nil, &block)
82
+ if block
83
+ @default_state_proc = block
84
+ else
85
+ @default_state = (hash || {}).freeze
86
+ end
87
+ end
88
+
89
+ # Return the resolved default state (evaluated fresh when a block was given).
90
+ def default_state
91
+ @default_state_proc ? @default_state_proc.call : @default_state
92
+ end
93
+
94
+ # Register a handler for messages of the given +type+.
95
+ def on(type, &handler)
96
+ @handlers[type.to_sym] = handler
97
+ end
98
+
99
+ # Register a recurring timer. The handler is called every +every+ seconds.
100
+ # Returning a Hash from the handler updates state; nil leaves it unchanged.
101
+ def schedule(name, every:, &handler)
102
+ @timers << { name: name.to_sym, interval: every.to_f, handler: handler }
103
+ end
104
+
105
+ # Maximum number of messages in the mailbox before overflow policy applies.
106
+ def mailbox_size(capacity)
107
+ @mailbox_capacity = capacity
108
+ end
109
+
110
+ # Overflow policy when the mailbox is full.
111
+ # One of: :block (default), :drop_oldest, :drop_newest, :error
112
+ def mailbox_overflow(policy)
113
+ @mailbox_overflow = policy
114
+ end
115
+
116
+ def after_start(&hook)
117
+ @hooks[:start] << hook
118
+ end
119
+
120
+ def after_crash(&hook)
121
+ @hooks[:crash] << hook
122
+ end
123
+
124
+ def after_stop(&hook)
125
+ @hooks[:stop] << hook
126
+ end
127
+
128
+ # ── Factory ─────────────────────────────────────────────────────────────
129
+
130
+ # Start the agent and return a Ref. The agent runs in a background Thread.
131
+ #
132
+ # Options:
133
+ # initial_state: Hash — override class-level default state
134
+ # on_crash: callable(error) — supervisor crash hook
135
+ # name: Symbol/String — register in Igniter::Registry under this name
136
+ #
137
+ def start(initial_state: nil, on_crash: nil, name: nil) # rubocop:disable Metrics/MethodLength
138
+ state_holder = StateHolder.new(initial_state || default_state)
139
+ mailbox = Mailbox.new(capacity: mailbox_capacity, overflow: @mailbox_overflow)
140
+ runner = Runner.new(
141
+ agent_class: self,
142
+ mailbox: mailbox,
143
+ state_holder: state_holder,
144
+ on_crash: on_crash
145
+ )
146
+ thread = runner.start
147
+ ref = Ref.new(thread: thread, mailbox: mailbox, state_holder: state_holder)
148
+
149
+ Igniter::Registry.register!(name, ref) if name
150
+
151
+ ref
152
+ end
153
+ end
154
+ end
155
+ end
@@ -49,6 +49,10 @@ module Igniter
49
49
  raise KeyError, "Unknown dependency '#{name}'"
50
50
  end
51
51
 
52
+ def await_nodes
53
+ @nodes.select { |n| n.kind == :await }
54
+ end
55
+
52
56
  def to_h
53
57
  {
54
58
  name: name,
@@ -80,6 +84,7 @@ module Igniter
80
84
  base[:mode] = node.mode
81
85
  base[:mapper] = node.input_mapper.to_s if node.input_mapper?
82
86
  end
87
+ base[:event] = node.event_name if node.kind == :await
83
88
  base
84
89
  end,
85
90
  outputs: outputs.map do |output|
@@ -117,6 +122,13 @@ module Igniter
117
122
  metadata: node.metadata.reject { |key, _| key == :source_location }
118
123
  }
119
124
  end,
125
+ awaits: nodes.select { |node| node.kind == :await }.map do |node|
126
+ {
127
+ name: node.name,
128
+ event: node.event_name,
129
+ metadata: node.metadata.reject { |key, _| key == :source_location }
130
+ }
131
+ end,
120
132
  branches: nodes.select { |node| node.kind == :branch }.map do |node|
121
133
  {
122
134
  name: node.name,
@@ -8,7 +8,9 @@ module Igniter
8
8
  Validators::OutputsValidator,
9
9
  Validators::DependenciesValidator,
10
10
  Validators::TypeCompatibilityValidator,
11
- Validators::CallableValidator
11
+ Validators::CallableValidator,
12
+ Validators::AwaitValidator,
13
+ Validators::RemoteValidator
12
14
  ].freeze
13
15
 
14
16
  def self.call(context, validators: DEFAULT_VALIDATORS)
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Compiler
5
+ module Validators
6
+ class AwaitValidator
7
+ def self.call(context)
8
+ new(context).call
9
+ end
10
+
11
+ def initialize(context)
12
+ @context = context
13
+ end
14
+
15
+ def call
16
+ await_nodes = @context.runtime_nodes.select { |n| n.kind == :await }
17
+ return if await_nodes.empty?
18
+
19
+ validate_correlation_keys_as_inputs!(await_nodes)
20
+ validate_unique_event_names!(await_nodes)
21
+ end
22
+
23
+ private
24
+
25
+ def validate_correlation_keys_as_inputs!(await_nodes) # rubocop:disable Metrics/AbcSize
26
+ correlation_keys = @context.graph.metadata[:correlation_keys] || []
27
+ return if correlation_keys.empty?
28
+
29
+ input_names = @context.runtime_nodes.select { |n| n.kind == :input }.map(&:name)
30
+ missing = correlation_keys.reject { |key| input_names.include?(key.to_sym) }
31
+ return if missing.empty?
32
+
33
+ raise @context.validation_error(
34
+ await_nodes.first,
35
+ "Correlation keys #{missing.inspect} must be declared as inputs"
36
+ )
37
+ end
38
+
39
+ def validate_unique_event_names!(await_nodes)
40
+ event_names = await_nodes.map(&:event_name)
41
+ duplicates = event_names.select { |e| event_names.count(e) > 1 }.uniq
42
+ return if duplicates.empty?
43
+
44
+ node = await_nodes.find { |n| duplicates.include?(n.event_name) }
45
+ raise @context.validation_error(
46
+ node,
47
+ "Duplicate await event names: #{duplicates.inspect}"
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -14,14 +14,32 @@ module Igniter
14
14
 
15
15
  def call
16
16
  @context.runtime_nodes.each do |node|
17
- next unless node.kind == :compute
18
-
19
- validate_callable_signature!(node)
17
+ if node.kind == :compute
18
+ validate_callable_signature!(node)
19
+ elsif node.kind == :effect
20
+ validate_effect_adapter!(node)
21
+ end
20
22
  end
21
23
  end
22
24
 
23
25
  private
24
26
 
27
+ def validate_effect_adapter!(node)
28
+ adapter = node.adapter_class
29
+ unless adapter.is_a?(Class) && adapter <= Igniter::Effect
30
+ raise @context.validation_error(
31
+ node,
32
+ "Effect '#{node.name}' adapter must be a subclass of Igniter::Effect"
33
+ )
34
+ end
35
+
36
+ validate_parameters_signature!(
37
+ node,
38
+ adapter.instance_method(:call).parameters,
39
+ adapter.name || "effect"
40
+ )
41
+ end
42
+
25
43
  def validate_callable_signature!(node)
26
44
  callable = node.callable
27
45
 
@@ -12,8 +12,10 @@ module Igniter
12
12
  @context = context
13
13
  end
14
14
 
15
- def call
15
+ def call # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
16
16
  @context.runtime_nodes.each do |node|
17
+ next if node.kind == :await
18
+
17
19
  validate_composition_node!(node) if node.kind == :composition
18
20
  validate_branch_node!(node) if node.kind == :branch
19
21
  validate_collection_node!(node) if node.kind == :collection
@@ -40,6 +42,7 @@ module Igniter
40
42
  end
41
43
 
42
44
  validate_composition_input_mapping!(node, contract_class.compiled_graph)
45
+ validate_composition_cycle!(node)
43
46
  end
44
47
 
45
48
  def validate_composition_input_mapping!(node, child_graph)
@@ -126,6 +129,43 @@ module Igniter
126
129
  )
127
130
  end
128
131
 
132
+ def validate_composition_cycle!(node)
133
+ child_contract = node.contract_class
134
+ return unless child_contract.respond_to?(:compiled_graph) && child_contract.compiled_graph
135
+
136
+ current_name = @context.graph.name
137
+ # Skip anonymous contracts to avoid false positives when multiple
138
+ # anonymous contracts share the same name "AnonymousContract"
139
+ return if current_name == "AnonymousContract"
140
+
141
+ validate_direct_cycle!(node, child_contract, current_name)
142
+ validate_grandchild_cycles!(node, child_contract, current_name)
143
+ end
144
+
145
+ def validate_direct_cycle!(node, child_contract, current_name)
146
+ return unless child_contract.compiled_graph.name == current_name
147
+
148
+ raise @context.validation_error(
149
+ node,
150
+ "Composition cycle: '#{node.name}' composes '#{child_contract.name}' " \
151
+ "which is the same contract ('#{current_name}')"
152
+ )
153
+ end
154
+
155
+ def validate_grandchild_cycles!(node, child_contract, current_name) # rubocop:disable Metrics/AbcSize
156
+ child_contract.compiled_graph.nodes.select { |n| n.kind == :composition }.each do |grandchild|
157
+ next unless grandchild.contract_class.respond_to?(:compiled_graph)
158
+ next unless grandchild.contract_class.compiled_graph
159
+ next unless grandchild.contract_class.compiled_graph.name == current_name
160
+
161
+ raise @context.validation_error(
162
+ node,
163
+ "Composition cycle: '#{node.name}' -> '#{child_contract.name}' -> " \
164
+ "'#{grandchild.contract_class.name}' loops back to '#{current_name}'"
165
+ )
166
+ end
167
+ end
168
+
129
169
  def validate_collection_node!(node)
130
170
  unless node.contract_class.is_a?(Class) && node.contract_class <= Igniter::Contract
131
171
  raise @context.validation_error(node, "Collection '#{node.name}' must reference an Igniter::Contract subclass")
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Compiler
5
+ module Validators
6
+ class RemoteValidator
7
+ def self.call(context)
8
+ new(context).call
9
+ end
10
+
11
+ def initialize(context)
12
+ @context = context
13
+ end
14
+
15
+ def call
16
+ @context.runtime_nodes.each do |node|
17
+ next unless node.kind == :remote
18
+
19
+ validate_url!(node)
20
+ validate_contract_name!(node)
21
+ validate_dependencies!(node)
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def validate_url!(node)
28
+ return if node.node_url.start_with?("http://", "https://")
29
+
30
+ raise @context.validation_error(
31
+ node,
32
+ "remote :#{node.name} has invalid node: URL '#{node.node_url}'. Must start with http:// or https://"
33
+ )
34
+ end
35
+
36
+ def validate_contract_name!(node)
37
+ return unless node.contract_name.strip.empty?
38
+
39
+ raise @context.validation_error(
40
+ node,
41
+ "remote :#{node.name} requires a non-empty contract: name"
42
+ )
43
+ end
44
+
45
+ def validate_dependencies!(node)
46
+ node.dependencies.each do |dep_name|
47
+ next if @context.dependency_resolvable?(dep_name)
48
+
49
+ raise @context.validation_error(
50
+ node,
51
+ "remote :#{node.name} depends on '#{dep_name}' which is not defined in the graph"
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -8,6 +8,8 @@ require_relative "compiler/validators/outputs_validator"
8
8
  require_relative "compiler/validators/dependencies_validator"
9
9
  require_relative "compiler/validators/callable_validator"
10
10
  require_relative "compiler/validators/type_compatibility_validator"
11
+ require_relative "compiler/validators/await_validator"
12
+ require_relative "compiler/validators/remote_validator"
11
13
  require_relative "compiler/validation_pipeline"
12
14
  require_relative "compiler/validator"
13
15
  require_relative "compiler/graph_compiler"
@@ -3,8 +3,20 @@
3
3
  module Igniter
4
4
  class Contract
5
5
  class << self
6
+ def correlate_by(*keys)
7
+ @correlation_keys = keys.map(&:to_sym).freeze
8
+ end
9
+
10
+ def correlation_keys
11
+ @correlation_keys || []
12
+ end
13
+
6
14
  def define(&block)
7
- @compiled_graph = DSL::ContractBuilder.compile(name: contract_name, &block)
15
+ @compiled_graph = DSL::ContractBuilder.compile(
16
+ name: contract_name,
17
+ correlation_keys: correlation_keys,
18
+ &block
19
+ )
8
20
  end
9
21
 
10
22
  def run_with(runner:, max_workers: nil)
@@ -28,6 +40,45 @@ module Igniter
28
40
  @compiled_graph = DSL::SchemaBuilder.compile(schema, name: contract_name)
29
41
  end
30
42
 
43
+ def start(inputs = {}, store: nil, **keyword_inputs)
44
+ resolved_store = store || Igniter.execution_store
45
+ all_inputs = inputs.merge(keyword_inputs)
46
+
47
+ instance = new(all_inputs, runner: :store, store: resolved_store)
48
+ instance.resolve_all
49
+
50
+ correlation = correlation_keys.each_with_object({}) do |key, hash|
51
+ hash[key] = all_inputs[key] || all_inputs[key.to_s]
52
+ end
53
+
54
+ resolved_store.save(instance.snapshot, correlation: correlation.compact, graph: contract_name)
55
+ instance
56
+ end
57
+
58
+ def deliver_event(event_name, correlation:, payload:, store: nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
59
+ resolved_store = store || Igniter.execution_store
60
+ execution_id = resolved_store.find_by_correlation(
61
+ graph: contract_name,
62
+ correlation: correlation.transform_keys(&:to_sym)
63
+ )
64
+ unless execution_id
65
+ raise ResolutionError,
66
+ "No pending execution found for #{contract_name} with given correlation"
67
+ end
68
+
69
+ instance = restore_from_store(execution_id, store: resolved_store)
70
+
71
+ await_node = instance.execution.compiled_graph.await_nodes
72
+ .find { |n| n.event_name == event_name.to_sym }
73
+ raise ResolutionError, "No await node found for event '#{event_name}' in #{contract_name}" unless await_node
74
+
75
+ instance.execution.resume(await_node.name, value: payload)
76
+ instance.resolve_all
77
+
78
+ resolved_store.save(instance.snapshot, correlation: correlation.transform_keys(&:to_sym), graph: contract_name)
79
+ instance
80
+ end
81
+
31
82
  def restore(snapshot)
32
83
  instance = new(
33
84
  snapshot[:inputs] || snapshot["inputs"] || {},
@@ -159,9 +210,9 @@ module Igniter
159
210
  end
160
211
  end
161
212
 
162
- attr_reader :execution, :result
213
+ attr_reader :execution, :result, :reactive
163
214
 
164
- def initialize(inputs = nil, runner: nil, max_workers: nil, **keyword_inputs)
215
+ def initialize(inputs = nil, runner: nil, max_workers: nil, store: nil, **keyword_inputs)
165
216
  graph = self.class.compiled_graph
166
217
  raise CompileError, "#{self.class.name} has no compiled graph. Use `define`." unless graph
167
218
 
@@ -175,7 +226,7 @@ module Igniter
175
226
  end
176
227
 
177
228
  execution_options = self.class.execution_options.merge(
178
- { runner: runner, max_workers: max_workers }.compact
229
+ { runner: runner, max_workers: max_workers, store: store }.compact
179
230
  )
180
231
  execution_options[:store] ||= Igniter.execution_store if execution_options[:runner]&.to_sym == :store
181
232
 
@@ -220,10 +271,6 @@ module Igniter
220
271
  execution.audit.snapshot
221
272
  end
222
273
 
223
- def reactive
224
- @reactive
225
- end
226
-
227
274
  def subscribe(subscriber = nil, &block)
228
275
  execution.events.subscribe(subscriber, &block)
229
276
  self
@@ -261,5 +308,9 @@ module Igniter
261
308
  def failed?
262
309
  execution.failed?
263
310
  end
311
+
312
+ def pending?
313
+ execution.pending?
314
+ end
264
315
  end
265
316
  end