igniter 0.3.1 → 0.4.0

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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
  3. data/examples/distributed_workflow.rb +52 -0
  4. data/lib/igniter/compiler/compiled_graph.rb +12 -0
  5. data/lib/igniter/compiler/validation_pipeline.rb +3 -1
  6. data/lib/igniter/compiler/validators/await_validator.rb +53 -0
  7. data/lib/igniter/compiler/validators/dependencies_validator.rb +41 -1
  8. data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
  9. data/lib/igniter/compiler.rb +2 -0
  10. data/lib/igniter/contract.rb +59 -8
  11. data/lib/igniter/dsl/contract_builder.rb +42 -4
  12. data/lib/igniter/errors.rb +6 -1
  13. data/lib/igniter/integrations/llm/config.rb +69 -0
  14. data/lib/igniter/integrations/llm/context.rb +74 -0
  15. data/lib/igniter/integrations/llm/executor.rb +159 -0
  16. data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
  17. data/lib/igniter/integrations/llm/providers/base.rb +33 -0
  18. data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
  19. data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
  20. data/lib/igniter/integrations/llm.rb +59 -0
  21. data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
  22. data/lib/igniter/integrations/rails/contract_job.rb +76 -0
  23. data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
  24. data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
  25. data/lib/igniter/integrations/rails/railtie.rb +25 -0
  26. data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
  27. data/lib/igniter/integrations/rails.rb +12 -0
  28. data/lib/igniter/model/await_node.rb +21 -0
  29. data/lib/igniter/model/remote_node.rb +26 -0
  30. data/lib/igniter/model.rb +2 -0
  31. data/lib/igniter/runtime/execution.rb +2 -2
  32. data/lib/igniter/runtime/input_validator.rb +5 -3
  33. data/lib/igniter/runtime/resolver.rb +43 -1
  34. data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
  35. data/lib/igniter/runtime/stores/file_store.rb +50 -2
  36. data/lib/igniter/runtime/stores/memory_store.rb +55 -2
  37. data/lib/igniter/runtime/stores/redis_store.rb +13 -1
  38. data/lib/igniter/server/client.rb +123 -0
  39. data/lib/igniter/server/config.rb +27 -0
  40. data/lib/igniter/server/handlers/base.rb +105 -0
  41. data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
  42. data/lib/igniter/server/handlers/event_handler.rb +28 -0
  43. data/lib/igniter/server/handlers/execute_handler.rb +37 -0
  44. data/lib/igniter/server/handlers/health_handler.rb +32 -0
  45. data/lib/igniter/server/handlers/status_handler.rb +27 -0
  46. data/lib/igniter/server/http_server.rb +109 -0
  47. data/lib/igniter/server/rack_app.rb +35 -0
  48. data/lib/igniter/server/registry.rb +56 -0
  49. data/lib/igniter/server/router.rb +75 -0
  50. data/lib/igniter/server.rb +67 -0
  51. data/lib/igniter/version.rb +1 -1
  52. data/lib/igniter.rb +4 -0
  53. metadata +36 -2
@@ -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
@@ -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,45 @@ 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 await(name, event:, **metadata)
252
+ add_node(
253
+ Model::AwaitNode.new(
254
+ id: next_id,
255
+ name: name.to_sym,
256
+ path: scoped_path(name),
257
+ event_name: event,
258
+ metadata: with_source_location(metadata)
259
+ )
260
+ )
261
+ end
262
+
231
263
  def compile
232
- Compiler::GraphCompiler.call(Model::Graph.new(name: @name, nodes: @nodes))
264
+ Compiler::GraphCompiler.call(
265
+ Model::Graph.new(
266
+ name: @name,
267
+ nodes: @nodes,
268
+ metadata: { correlation_keys: @correlation_keys || [] }
269
+ )
270
+ )
233
271
  end
234
272
 
235
273
  private
@@ -29,13 +29,18 @@ 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?
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module LLM
5
+ class Config
6
+ class OllamaConfig
7
+ attr_accessor :base_url, :default_model, :timeout
8
+
9
+ def initialize
10
+ @base_url = "http://localhost:11434"
11
+ @default_model = "llama3.2"
12
+ @timeout = 120
13
+ end
14
+ end
15
+
16
+ class AnthropicConfig
17
+ attr_accessor :api_key, :base_url, :default_model, :timeout
18
+
19
+ def initialize
20
+ @api_key = ENV["ANTHROPIC_API_KEY"]
21
+ @base_url = "https://api.anthropic.com"
22
+ @default_model = "claude-sonnet-4-6"
23
+ @timeout = 120
24
+ end
25
+ end
26
+
27
+ class OpenAIConfig
28
+ attr_accessor :api_key, :base_url, :default_model, :timeout
29
+
30
+ def initialize
31
+ @api_key = ENV["OPENAI_API_KEY"]
32
+ @base_url = "https://api.openai.com"
33
+ @default_model = "gpt-4o"
34
+ @timeout = 120
35
+ end
36
+ end
37
+
38
+ PROVIDERS = %i[ollama anthropic openai].freeze
39
+
40
+ attr_accessor :default_provider
41
+ attr_reader :providers
42
+
43
+ def initialize
44
+ @default_provider = :ollama
45
+ @providers = {
46
+ ollama: OllamaConfig.new,
47
+ anthropic: AnthropicConfig.new,
48
+ openai: OpenAIConfig.new
49
+ }
50
+ end
51
+
52
+ def ollama
53
+ @providers[:ollama]
54
+ end
55
+
56
+ def anthropic
57
+ @providers[:anthropic]
58
+ end
59
+
60
+ def openai
61
+ @providers[:openai]
62
+ end
63
+
64
+ def provider_config(name)
65
+ @providers.fetch(name.to_sym) { raise ArgumentError, "Unknown LLM provider: #{name}. Available: #{PROVIDERS.inspect}" }
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module LLM
5
+ # Manages a conversation message history for multi-turn LLM interactions.
6
+ # Immutable — each operation returns a new Context.
7
+ #
8
+ # Usage:
9
+ # ctx = Igniter::LLM::Context.empty(system: "You are a helpful assistant.")
10
+ # ctx = ctx.append_user("What is Ruby?")
11
+ # ctx = ctx.append_assistant("Ruby is a dynamic language...")
12
+ # ctx.messages # => [{role: :system, ...}, {role: :user, ...}, {role: :assistant, ...}]
13
+ class Context
14
+ attr_reader :messages
15
+
16
+ def initialize(messages = [])
17
+ @messages = messages.freeze
18
+ end
19
+
20
+ def self.empty(system: nil)
21
+ initial = system ? [{ role: :system, content: system }] : []
22
+ new(initial)
23
+ end
24
+
25
+ def self.from_h(data)
26
+ msgs = (data[:messages] || data["messages"] || []).map do |m|
27
+ { role: (m[:role] || m["role"]).to_sym, content: (m[:content] || m["content"]).to_s }
28
+ end
29
+ new(msgs)
30
+ end
31
+
32
+ def append_user(content)
33
+ append(role: :user, content: content)
34
+ end
35
+
36
+ def append_assistant(content)
37
+ append(role: :assistant, content: content)
38
+ end
39
+
40
+ def append_tool_result(tool_name, content)
41
+ append(role: :tool, content: content.to_s, name: tool_name.to_s)
42
+ end
43
+
44
+ def append(message)
45
+ self.class.new(@messages + [message.transform_keys(&:to_sym)])
46
+ end
47
+
48
+ def with_system(content)
49
+ existing = @messages.reject { |m| m[:role] == :system }
50
+ self.class.new([{ role: :system, content: content }] + existing)
51
+ end
52
+
53
+ def last_assistant_message
54
+ @messages.reverse.find { |m| m[:role] == :assistant }
55
+ end
56
+
57
+ def length
58
+ @messages.length
59
+ end
60
+
61
+ def empty?
62
+ @messages.empty?
63
+ end
64
+
65
+ def to_a
66
+ @messages.map { |m| { "role" => m[:role].to_s, "content" => m[:content].to_s } }
67
+ end
68
+
69
+ def to_h
70
+ { messages: @messages }
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module LLM
5
+ # Base class for LLM-powered compute nodes.
6
+ #
7
+ # Subclass and override #call(**inputs) to build prompts and get completions.
8
+ # Use the #complete and #chat helper methods inside #call.
9
+ #
10
+ # Example:
11
+ # class DocumentSummarizer < Igniter::LLM::Executor
12
+ # provider :ollama
13
+ # model "llama3.2"
14
+ # system_prompt "You are a concise technical writer."
15
+ #
16
+ # def call(document:)
17
+ # complete("Summarize this in 3 bullet points:\n\n#{document}")
18
+ # end
19
+ # end
20
+ #
21
+ # class OrderContract < Igniter::Contract
22
+ # define do
23
+ # input :document
24
+ # compute :summary, depends_on: :document, with: DocumentSummarizer
25
+ # output :summary
26
+ # end
27
+ # end
28
+ class Executor < Igniter::Executor
29
+ class << self
30
+ def provider(name = nil)
31
+ return @provider || Igniter::LLM.config.default_provider if name.nil?
32
+
33
+ @provider = name.to_sym
34
+ end
35
+
36
+ def model(name = nil)
37
+ return @model || provider_config.default_model if name.nil?
38
+
39
+ @model = name
40
+ end
41
+
42
+ def system_prompt(text = nil)
43
+ return @system_prompt if text.nil?
44
+
45
+ @system_prompt = text
46
+ end
47
+
48
+ def temperature(val = nil)
49
+ return @temperature if val.nil?
50
+
51
+ @temperature = val
52
+ end
53
+
54
+ def tools(*tool_definitions)
55
+ return @tools || [] if tool_definitions.empty?
56
+
57
+ @tools = tool_definitions.flatten
58
+ end
59
+
60
+ def inherited(subclass)
61
+ super
62
+ subclass.instance_variable_set(:@provider, @provider)
63
+ subclass.instance_variable_set(:@model, @model)
64
+ subclass.instance_variable_set(:@system_prompt, @system_prompt)
65
+ subclass.instance_variable_set(:@temperature, @temperature)
66
+ subclass.instance_variable_set(:@tools, @tools&.dup)
67
+ end
68
+
69
+ private
70
+
71
+ def provider_config
72
+ Igniter::LLM.provider_instance(provider).instance_of?(Class) ? nil : Igniter::LLM.config.provider_config(provider)
73
+ rescue StandardError
74
+ Igniter::LLM.config.ollama
75
+ end
76
+ end
77
+
78
+ attr_reader :last_usage, :last_context
79
+
80
+ # Subclasses override this method. Use #complete or #chat inside.
81
+ def call(**_inputs)
82
+ raise NotImplementedError, "#{self.class.name}#call must be implemented"
83
+ end
84
+
85
+ protected
86
+
87
+ # Single-turn completion. Builds a simple user message from prompt.
88
+ def complete(prompt, context: nil)
89
+ messages = build_messages(prompt: prompt, context: context)
90
+ response = provider_instance.chat(
91
+ messages: messages,
92
+ model: self.class.model,
93
+ **completion_options
94
+ )
95
+ @last_usage = provider_instance.last_usage
96
+ @last_context = track_context(context, prompt, response[:content])
97
+ response[:content]
98
+ end
99
+
100
+ # Multi-turn chat with a Context object or raw messages array.
101
+ def chat(context:)
102
+ messages = context.is_a?(Context) ? context.to_a : Array(context)
103
+ response = provider_instance.chat(
104
+ messages: messages,
105
+ model: self.class.model,
106
+ **completion_options
107
+ )
108
+ @last_usage = provider_instance.last_usage
109
+ response[:content]
110
+ end
111
+
112
+ # Tool-use call. Returns DeferredResult if the LLM wants to call a tool.
113
+ def complete_with_tools(prompt, context: nil) # rubocop:disable Metrics/MethodLength
114
+ messages = build_messages(prompt: prompt, context: context)
115
+ response = provider_instance.chat(
116
+ messages: messages,
117
+ model: self.class.model,
118
+ tools: self.class.tools,
119
+ **completion_options
120
+ )
121
+ @last_usage = provider_instance.last_usage
122
+
123
+ if response[:tool_calls].any?
124
+ defer(payload: { tool_calls: response[:tool_calls], messages: messages })
125
+ else
126
+ response[:content]
127
+ end
128
+ end
129
+
130
+ private
131
+
132
+ def provider_instance
133
+ @provider_instance ||= Igniter::LLM.provider_instance(self.class.provider)
134
+ end
135
+
136
+ def build_messages(prompt:, context: nil)
137
+ base = []
138
+ base << { role: "system", content: self.class.system_prompt } if self.class.system_prompt
139
+
140
+ if context.is_a?(Context)
141
+ base + context.to_a + [{ role: "user", content: prompt }]
142
+ else
143
+ base + [{ role: "user", content: prompt }]
144
+ end
145
+ end
146
+
147
+ def completion_options
148
+ opts = {}
149
+ opts[:temperature] = self.class.temperature if self.class.temperature
150
+ opts
151
+ end
152
+
153
+ def track_context(existing, user_prompt, assistant_reply)
154
+ ctx = existing.is_a?(Context) ? existing : Context.empty(system: self.class.system_prompt)
155
+ ctx.append_user(user_prompt).append_assistant(assistant_reply)
156
+ end
157
+ end
158
+ end
159
+ end