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,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Igniter
8
+ module LLM
9
+ module Providers
10
+ # OpenAI provider (also compatible with Azure OpenAI and any OpenAI-compatible API).
11
+ # Requires OPENAI_API_KEY environment variable or explicit api_key:.
12
+ #
13
+ # API docs: https://platform.openai.com/docs/api-reference/chat
14
+ #
15
+ # Compatible with: OpenAI, Azure OpenAI, Groq, Together AI,
16
+ # Mistral, DeepSeek, and any OpenAI-compatible endpoint.
17
+ class OpenAI < Base # rubocop:disable Metrics/ClassLength
18
+ API_BASE = "https://api.openai.com"
19
+
20
+ def initialize(api_key: ENV["OPENAI_API_KEY"], base_url: API_BASE, timeout: 120)
21
+ super()
22
+ @api_key = api_key
23
+ @base_url = base_url.chomp("/")
24
+ @timeout = timeout
25
+ end
26
+
27
+ # Send a chat completion request.
28
+ def chat(messages:, model:, tools: [], **options) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
29
+ validate_api_key!
30
+
31
+ body = {
32
+ model: model,
33
+ messages: normalize_messages(messages)
34
+ }
35
+ body[:tools] = normalize_tools(tools) if tools.any?
36
+ body[:temperature] = options[:temperature] if options.key?(:temperature)
37
+ body[:top_p] = options[:top_p] if options.key?(:top_p)
38
+ body[:max_tokens] = options[:max_tokens] if options.key?(:max_tokens)
39
+ body[:seed] = options[:seed] if options.key?(:seed)
40
+ body[:stop] = options[:stop] if options.key?(:stop)
41
+
42
+ response = post("/v1/chat/completions", body)
43
+ parse_response(response)
44
+ end
45
+
46
+ private
47
+
48
+ def parse_response(response) # rubocop:disable Metrics/MethodLength
49
+ message = response.dig("choices", 0, "message") || {}
50
+ usage = response.fetch("usage", {})
51
+
52
+ record_usage(
53
+ prompt_tokens: usage["prompt_tokens"] || 0,
54
+ completion_tokens: usage["completion_tokens"] || 0
55
+ )
56
+
57
+ {
58
+ role: (message["role"] || "assistant").to_sym,
59
+ content: message["content"].to_s,
60
+ tool_calls: parse_tool_calls(message["tool_calls"])
61
+ }
62
+ end
63
+
64
+ def parse_tool_calls(raw)
65
+ return [] unless raw.is_a?(Array)
66
+
67
+ raw.map do |tc|
68
+ fn = tc["function"] || {}
69
+ {
70
+ name: fn["name"].to_s,
71
+ arguments: parse_arguments(fn["arguments"])
72
+ }
73
+ end
74
+ end
75
+
76
+ def parse_arguments(args)
77
+ case args
78
+ when Hash then args.transform_keys(&:to_sym)
79
+ when String then JSON.parse(args).transform_keys(&:to_sym)
80
+ else {}
81
+ end
82
+ rescue JSON::ParserError
83
+ {}
84
+ end
85
+
86
+ def normalize_messages(messages)
87
+ messages.map do |m|
88
+ { "role" => (m[:role] || m["role"]).to_s, "content" => (m[:content] || m["content"]).to_s }
89
+ end
90
+ end
91
+
92
+ def normalize_tools(tools)
93
+ tools.map do |tool|
94
+ {
95
+ "type" => "function",
96
+ "function" => {
97
+ "name" => tool[:name].to_s,
98
+ "description" => tool[:description].to_s,
99
+ "parameters" => tool.fetch(:parameters) { { "type" => "object", "properties" => {} } }
100
+ }
101
+ }
102
+ end
103
+ end
104
+
105
+ def post(path, body) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
106
+ uri = URI.parse("#{@base_url}#{path}")
107
+ http = Net::HTTP.new(uri.host, uri.port)
108
+ http.use_ssl = uri.scheme == "https"
109
+ http.read_timeout = @timeout
110
+ http.open_timeout = 10
111
+
112
+ request = Net::HTTP::Post.new(uri.path, headers)
113
+ request.body = JSON.generate(body)
114
+
115
+ response = http.request(request)
116
+ handle_response(response)
117
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e
118
+ raise Igniter::LLM::ProviderError, "Cannot connect to OpenAI API at #{@base_url}: #{e.message}"
119
+ end
120
+
121
+ def headers
122
+ {
123
+ "Content-Type" => "application/json",
124
+ "Authorization" => "Bearer #{@api_key}"
125
+ }
126
+ end
127
+
128
+ def handle_response(response) # rubocop:disable Metrics/MethodLength
129
+ unless response.is_a?(Net::HTTPSuccess)
130
+ body = begin
131
+ JSON.parse(response.body)
132
+ rescue StandardError
133
+ {}
134
+ end
135
+ error_msg = body.dig("error", "message") || response.body.to_s.slice(0, 200)
136
+ raise Igniter::LLM::ProviderError, "OpenAI API error #{response.code}: #{error_msg}"
137
+ end
138
+
139
+ JSON.parse(response.body)
140
+ rescue JSON::ParserError => e
141
+ raise Igniter::LLM::ProviderError, "OpenAI returned invalid JSON: #{e.message}"
142
+ end
143
+
144
+ def validate_api_key!
145
+ return if @api_key && !@api_key.empty?
146
+
147
+ raise Igniter::LLM::ConfigurationError,
148
+ "OpenAI API key not configured. Set OPENAI_API_KEY or pass api_key: to the provider."
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require_relative "llm/config"
5
+ require_relative "llm/context"
6
+ require_relative "llm/providers/base"
7
+ require_relative "llm/providers/ollama"
8
+ require_relative "llm/providers/anthropic"
9
+ require_relative "llm/providers/openai"
10
+ require_relative "llm/executor"
11
+
12
+ module Igniter
13
+ module LLM
14
+ class Error < Igniter::Error; end
15
+ class ProviderError < Error; end
16
+ class ConfigurationError < Error; end
17
+
18
+ AVAILABLE_PROVIDERS = Config::PROVIDERS
19
+
20
+ class << self
21
+ def config
22
+ @config ||= Config.new
23
+ end
24
+
25
+ def configure
26
+ yield config
27
+ end
28
+
29
+ # Returns a memoized provider instance for the given provider name.
30
+ def provider_instance(name)
31
+ @provider_instances ||= {}
32
+ @provider_instances[name.to_sym] ||= build_provider(name.to_sym)
33
+ end
34
+
35
+ # Reset cached provider instances (useful after reconfiguration).
36
+ def reset_providers!
37
+ @provider_instances = nil
38
+ end
39
+
40
+ private
41
+
42
+ def build_provider(name)
43
+ case name
44
+ when :ollama
45
+ cfg = config.ollama
46
+ Providers::Ollama.new(base_url: cfg.base_url, timeout: cfg.timeout)
47
+ when :anthropic
48
+ cfg = config.anthropic
49
+ Providers::Anthropic.new(api_key: cfg.api_key, base_url: cfg.base_url, timeout: cfg.timeout)
50
+ when :openai
51
+ cfg = config.openai
52
+ Providers::OpenAI.new(api_key: cfg.api_key, base_url: cfg.base_url, timeout: cfg.timeout)
53
+ else
54
+ raise ConfigurationError, "Unknown LLM provider: #{name}. Available: #{AVAILABLE_PROVIDERS.inspect}"
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Rails
5
+ # ActionCable channel mixin for streaming contract execution events.
6
+ #
7
+ # Usage:
8
+ # class OrderChannel < ApplicationCable::Channel
9
+ # include Igniter::Rails::CableAdapter
10
+ #
11
+ # subscribed do
12
+ # stream_contract(OrderContract, execution_id: params[:execution_id])
13
+ # end
14
+ # end
15
+ #
16
+ # Broadcasts events as:
17
+ # { type: "node_succeeded", node: "payment", status: "succeeded", payload: { ... } }
18
+ module CableAdapter
19
+ def stream_contract(contract_class, execution_id:, store: nil)
20
+ resolved_store = store || Igniter.execution_store
21
+ snapshot = resolved_store.fetch(execution_id)
22
+ instance = contract_class.restore(snapshot)
23
+
24
+ instance.subscribe do |event|
25
+ broadcast_igniter_event(event, execution_id)
26
+ end
27
+
28
+ @_igniter_executions ||= []
29
+ @_igniter_executions << instance
30
+ rescue Igniter::ResolutionError => e
31
+ transmit({ type: "error", message: e.message })
32
+ end
33
+
34
+ private
35
+
36
+ def broadcast_igniter_event(event, execution_id)
37
+ transmit({
38
+ type: event.type.to_s,
39
+ execution_id: execution_id,
40
+ node: event.node_name,
41
+ path: event.path,
42
+ status: event.status,
43
+ payload: event.payload,
44
+ timestamp: event.timestamp&.iso8601
45
+ }.compact)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Rails
5
+ # Base ActiveJob class for async contract execution.
6
+ #
7
+ # Usage:
8
+ # class ProcessOrderJob < Igniter::Rails::ContractJob
9
+ # contract OrderContract
10
+ # end
11
+ #
12
+ # ProcessOrderJob.perform_later(order_id: "ord-123")
13
+ # ProcessOrderJob.perform_now(order_id: "ord-123")
14
+ #
15
+ # The job starts the contract and persists it to the configured store.
16
+ # If the contract has correlation keys, the execution can be resumed
17
+ # later via Contract.deliver_event.
18
+ class ContractJob
19
+ # No dependency on ActiveJob here — this class acts as a blueprint.
20
+ # When Rails is present, subclasses inherit from ApplicationJob automatically
21
+ # if the user adds `< ApplicationJob` (the recommended pattern).
22
+
23
+ class << self
24
+ def contract(klass = nil)
25
+ @contract_class = klass if klass
26
+ @contract_class
27
+ end
28
+
29
+ def store(store_instance = nil)
30
+ @store = store_instance if store_instance
31
+ @store || Igniter.execution_store
32
+ end
33
+
34
+ # Wraps perform in an ActiveJob-compatible interface.
35
+ # Call this when Rails is available to get ActiveJob queueing.
36
+ def perform_later(**inputs)
37
+ if defined?(::ActiveJob::Base)
38
+ ActiveJobAdapter.perform_later(contract_class: contract, inputs: inputs, store: store)
39
+ else
40
+ perform_now(**inputs)
41
+ end
42
+ end
43
+
44
+ def perform_now(**inputs)
45
+ contract.start(inputs, store: store)
46
+ end
47
+ end
48
+
49
+ # Included by ActiveJobAdapter to bridge ActiveJob lifecycle.
50
+ module Perform
51
+ def perform(contract_class_name:, inputs:, store_class: nil, store_config: nil)
52
+ klass = Object.const_get(contract_class_name)
53
+ resolved_store = resolve_store(store_class, store_config)
54
+ klass.start(inputs.transform_keys(&:to_sym), store: resolved_store)
55
+ end
56
+
57
+ private
58
+
59
+ def resolve_store(store_class, _store_config)
60
+ return Igniter.execution_store unless store_class
61
+
62
+ Object.const_get(store_class).new
63
+ end
64
+ end
65
+ end
66
+
67
+ # ActiveJob adapter — only defined when ActiveJob is available.
68
+ if defined?(::ActiveJob::Base)
69
+ class ActiveJobAdapter < ::ActiveJob::Base
70
+ include ContractJob::Perform
71
+
72
+ queue_as :igniter
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Igniter
6
+ module Rails
7
+ module Generators
8
+ class ContractGenerator < ::Rails::Generators::NamedBase
9
+ source_root File.expand_path("templates", __dir__)
10
+ desc "Creates an Igniter contract."
11
+
12
+ class_option :correlate_by, type: :array, default: [], desc: "Correlation key names"
13
+ class_option :inputs, type: :array, default: [], desc: "Input names"
14
+ class_option :outputs, type: :array, default: ["result"], desc: "Output names"
15
+
16
+ def create_contract
17
+ template "contract.rb.tt", File.join("app/contracts", class_path, "#{file_name}_contract.rb")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Igniter
6
+ module Rails
7
+ module Generators
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+ desc "Creates an Igniter initializer in your application."
11
+
12
+ def copy_initializer
13
+ template "igniter.rb.tt", "config/initializers/igniter.rb"
14
+ end
15
+
16
+ def create_contracts_directory
17
+ empty_directory "app/contracts"
18
+ create_file "app/contracts/.keep"
19
+ end
20
+
21
+ def show_readme
22
+ say "", :green
23
+ say "✓ Igniter installed!", :green
24
+ say ""
25
+ say "Next steps:"
26
+ say " 1. Configure your store in config/initializers/igniter.rb"
27
+ say " 2. Generate a contract: rails g igniter:contract YourContractName"
28
+ say ""
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Rails
5
+ class Railtie < ::Rails::Railtie
6
+ initializer "igniter.configure_store" do
7
+ # Auto-configure store based on available adapters unless already set
8
+ next if Igniter.instance_variable_defined?(:@execution_store)
9
+
10
+ Igniter.execution_store =
11
+ if defined?(Redis) && ::Rails.application.config.respond_to?(:redis)
12
+ Igniter::Runtime::Stores::RedisStore.new(::Redis.current)
13
+ else
14
+ Igniter::Runtime::Stores::MemoryStore.new
15
+ end
16
+ end
17
+
18
+ initializer "igniter.load_contracts" do
19
+ ::Rails.autoloaders.main.on_load("ApplicationContract") do
20
+ # Hook point for future eager loading of compiled contracts
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Rails
5
+ # Controller mixin for delivering external events to running contracts.
6
+ #
7
+ # Usage:
8
+ # class WebhooksController < ApplicationController
9
+ # include Igniter::Rails::WebhookHandler
10
+ #
11
+ # def stripe
12
+ # deliver_event_for(
13
+ # OrderContract,
14
+ # event: :stripe_payment_succeeded,
15
+ # correlation_from: { order_id: params[:metadata][:order_id] },
16
+ # payload: params.to_unsafe_h
17
+ # )
18
+ # end
19
+ # end
20
+ module WebhookHandler
21
+ def deliver_event_for(contract_class, event:, correlation_from:, payload: nil, store: nil) # rubocop:disable Metrics/MethodLength
22
+ payload_data = payload || (respond_to?(:params) ? params.to_unsafe_h : {})
23
+ correlation = extract_correlation(correlation_from)
24
+
25
+ contract_class.deliver_event(
26
+ event,
27
+ correlation: correlation,
28
+ payload: payload_data,
29
+ store: store || Igniter.execution_store
30
+ )
31
+
32
+ head :ok
33
+ rescue Igniter::ResolutionError => e
34
+ render json: { error: e.message }, status: :unprocessable_entity
35
+ end
36
+
37
+ private
38
+
39
+ def extract_correlation(source)
40
+ case source
41
+ when Hash then source.transform_keys(&:to_sym)
42
+ when Symbol then { source => params[source] }
43
+ when Array then source.each_with_object({}) { |k, h| h[k.to_sym] = params[k] }
44
+ else source.to_h.transform_keys(&:to_sym)
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter"
4
+ require_relative "rails/railtie" if defined?(::Rails::Railtie)
5
+ require_relative "rails/contract_job"
6
+ require_relative "rails/webhook_concern"
7
+ require_relative "rails/cable_adapter"
8
+
9
+ module Igniter
10
+ module Rails
11
+ end
12
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ # Represents a named condition that must always hold for a contract's outputs.
5
+ #
6
+ # An Invariant wraps a block that receives the contract's declared output values
7
+ # as keyword arguments and returns a truthy value when the condition holds.
8
+ #
9
+ # @example
10
+ # inv = Igniter::Invariant.new(:total_non_negative) { |total:, **| total >= 0 }
11
+ # inv.check(total: 42) # => nil (passed)
12
+ # inv.check(total: -1) # => InvariantViolation
13
+ class Invariant
14
+ attr_reader :name, :block
15
+
16
+ def initialize(name, &block)
17
+ raise ArgumentError, "invariant :#{name} requires a block" unless block
18
+
19
+ @name = name.to_sym
20
+ @block = block
21
+ freeze
22
+ end
23
+
24
+ # Evaluate invariant against the resolved output values.
25
+ #
26
+ # @param resolved_values [Hash] output_name => value for all declared outputs
27
+ # @return [InvariantViolation, nil] nil when the invariant holds
28
+ def check(resolved_values)
29
+ passed = block.call(**resolved_values)
30
+ passed ? nil : InvariantViolation.new(name: name, passed: false)
31
+ rescue StandardError => e
32
+ InvariantViolation.new(name: name, passed: false, error: e)
33
+ end
34
+ end
35
+
36
+ # Records a single invariant check outcome.
37
+ class InvariantViolation
38
+ attr_reader :name, :error
39
+
40
+ def initialize(name:, passed:, error: nil)
41
+ @name = name.to_sym
42
+ @passed = passed
43
+ @error = error
44
+ freeze
45
+ end
46
+
47
+ def passed? = @passed
48
+ def failed? = !@passed
49
+ end
50
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Model
5
+ class AwaitNode < Node
6
+ attr_reader :event_name
7
+
8
+ def initialize(id:, name:, event_name:, path: nil, metadata: {})
9
+ super(
10
+ id: id,
11
+ kind: :await,
12
+ name: name,
13
+ path: path || name.to_s,
14
+ dependencies: [],
15
+ metadata: metadata
16
+ )
17
+ @event_name = event_name.to_sym
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Model
5
+ # Represents a side-effect node in the computation graph.
6
+ #
7
+ # An EffectNode wraps an Igniter::Effect adapter class. It participates
8
+ # in topological ordering, dependency resolution, saga compensations,
9
+ # and execution reporting like any other node — but is explicitly typed
10
+ # as a side effect for visibility and audit purposes.
11
+ class EffectNode < Node
12
+ attr_reader :adapter_class
13
+
14
+ def initialize(id:, name:, dependencies:, adapter_class:, path: nil, metadata: {}) # rubocop:disable Metrics/ParameterLists
15
+ super(
16
+ id: id,
17
+ kind: :effect,
18
+ name: name,
19
+ path: path || name,
20
+ dependencies: dependencies,
21
+ metadata: metadata
22
+ )
23
+ @adapter_class = adapter_class
24
+ end
25
+
26
+ # @return [Symbol] e.g. :database, :http, :cache, :generic
27
+ def effect_type
28
+ adapter_class.effect_type
29
+ end
30
+
31
+ # @return [Boolean]
32
+ def idempotent?
33
+ adapter_class.idempotent?
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Model
5
+ # Represents a node that executes a contract on a remote igniter-server node.
6
+ # The result is the outputs hash returned by the remote contract.
7
+ class RemoteNode < Node
8
+ attr_reader :contract_name, :node_url, :input_mapping, :timeout
9
+
10
+ def initialize(id:, name:, contract_name:, node_url:, input_mapping:, timeout: 30, path: nil, metadata: {})
11
+ super(
12
+ id: id,
13
+ kind: :remote,
14
+ name: name,
15
+ path: path || name.to_s,
16
+ dependencies: input_mapping.values.map(&:to_sym),
17
+ metadata: metadata
18
+ )
19
+ @contract_name = contract_name.to_s
20
+ @node_url = node_url.to_s
21
+ @input_mapping = input_mapping.transform_keys(&:to_sym).transform_values(&:to_sym).freeze
22
+ @timeout = Integer(timeout)
23
+ end
24
+ end
25
+ end
26
+ end
data/lib/igniter/model.rb CHANGED
@@ -8,6 +8,9 @@ require_relative "model/composition_node"
8
8
  require_relative "model/branch_node"
9
9
  require_relative "model/collection_node"
10
10
  require_relative "model/output_node"
11
+ require_relative "model/await_node"
12
+ require_relative "model/remote_node"
13
+ require_relative "model/effect_node"
11
14
 
12
15
  module Igniter
13
16
  module Model