igniter 0.3.0 → 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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/README.md +2 -2
  4. data/docs/API_V2.md +58 -0
  5. data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
  6. data/examples/README.md +3 -0
  7. data/examples/distributed_workflow.rb +52 -0
  8. data/examples/ringcentral_routing.rb +26 -35
  9. data/lib/igniter/compiler/compiled_graph.rb +20 -0
  10. data/lib/igniter/compiler/validation_pipeline.rb +3 -1
  11. data/lib/igniter/compiler/validators/await_validator.rb +53 -0
  12. data/lib/igniter/compiler/validators/dependencies_validator.rb +43 -1
  13. data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
  14. data/lib/igniter/compiler.rb +2 -0
  15. data/lib/igniter/contract.rb +75 -8
  16. data/lib/igniter/diagnostics/report.rb +102 -3
  17. data/lib/igniter/dsl/contract_builder.rb +109 -8
  18. data/lib/igniter/errors.rb +6 -1
  19. data/lib/igniter/extensions/introspection/graph_formatter.rb +4 -0
  20. data/lib/igniter/integrations/llm/config.rb +69 -0
  21. data/lib/igniter/integrations/llm/context.rb +74 -0
  22. data/lib/igniter/integrations/llm/executor.rb +159 -0
  23. data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
  24. data/lib/igniter/integrations/llm/providers/base.rb +33 -0
  25. data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
  26. data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
  27. data/lib/igniter/integrations/llm.rb +59 -0
  28. data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
  29. data/lib/igniter/integrations/rails/contract_job.rb +76 -0
  30. data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
  31. data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
  32. data/lib/igniter/integrations/rails/railtie.rb +25 -0
  33. data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
  34. data/lib/igniter/integrations/rails.rb +12 -0
  35. data/lib/igniter/model/await_node.rb +21 -0
  36. data/lib/igniter/model/branch_node.rb +9 -3
  37. data/lib/igniter/model/collection_node.rb +9 -3
  38. data/lib/igniter/model/remote_node.rb +26 -0
  39. data/lib/igniter/model.rb +2 -0
  40. data/lib/igniter/runtime/execution.rb +2 -2
  41. data/lib/igniter/runtime/input_validator.rb +5 -3
  42. data/lib/igniter/runtime/resolver.rb +91 -8
  43. data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
  44. data/lib/igniter/runtime/stores/file_store.rb +50 -2
  45. data/lib/igniter/runtime/stores/memory_store.rb +55 -2
  46. data/lib/igniter/runtime/stores/redis_store.rb +13 -1
  47. data/lib/igniter/server/client.rb +123 -0
  48. data/lib/igniter/server/config.rb +27 -0
  49. data/lib/igniter/server/handlers/base.rb +105 -0
  50. data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
  51. data/lib/igniter/server/handlers/event_handler.rb +28 -0
  52. data/lib/igniter/server/handlers/execute_handler.rb +37 -0
  53. data/lib/igniter/server/handlers/health_handler.rb +32 -0
  54. data/lib/igniter/server/handlers/status_handler.rb +27 -0
  55. data/lib/igniter/server/http_server.rb +109 -0
  56. data/lib/igniter/server/rack_app.rb +35 -0
  57. data/lib/igniter/server/registry.rb +56 -0
  58. data/lib/igniter/server/router.rb +75 -0
  59. data/lib/igniter/server.rb +67 -0
  60. data/lib/igniter/version.rb +1 -1
  61. data/lib/igniter.rb +4 -0
  62. metadata +36 -2
@@ -3,10 +3,10 @@
3
3
  module Igniter
4
4
  module Model
5
5
  class BranchNode < Node
6
- attr_reader :selector_dependency, :cases, :default_contract, :input_mapping
6
+ attr_reader :selector_dependency, :cases, :default_contract, :input_mapping, :context_dependencies, :input_mapper
7
7
 
8
- def initialize(id:, name:, selector_dependency:, cases:, default_contract:, input_mapping:, path: nil, metadata: {})
9
- dependencies = ([selector_dependency] + input_mapping.values).uniq
8
+ def initialize(id:, name:, selector_dependency:, cases:, default_contract:, input_mapping:, context_dependencies: [], input_mapper: nil, path: nil, metadata: {})
9
+ dependencies = ([selector_dependency] + input_mapping.values + context_dependencies).uniq
10
10
 
11
11
  super(
12
12
  id: id,
@@ -21,12 +21,18 @@ module Igniter
21
21
  @cases = cases.map { |entry| normalize_case(entry) }.freeze
22
22
  @default_contract = default_contract
23
23
  @input_mapping = input_mapping.transform_keys(&:to_sym).transform_values(&:to_sym).freeze
24
+ @context_dependencies = Array(context_dependencies).map(&:to_sym).freeze
25
+ @input_mapper = input_mapper
24
26
  end
25
27
 
26
28
  def possible_contracts
27
29
  (cases.map { |entry| entry[:contract] } + [default_contract]).uniq
28
30
  end
29
31
 
32
+ def input_mapper?
33
+ !input_mapper.nil?
34
+ end
35
+
30
36
  private
31
37
 
32
38
  def normalize_case(entry)
@@ -3,15 +3,15 @@
3
3
  module Igniter
4
4
  module Model
5
5
  class CollectionNode < Node
6
- attr_reader :source_dependency, :contract_class, :key_name, :mode
6
+ attr_reader :source_dependency, :contract_class, :key_name, :mode, :context_dependencies, :input_mapper
7
7
 
8
- def initialize(id:, name:, source_dependency:, contract_class:, key_name:, mode:, path: nil, metadata: {})
8
+ def initialize(id:, name:, source_dependency:, contract_class:, key_name:, mode:, context_dependencies: [], input_mapper: nil, path: nil, metadata: {})
9
9
  super(
10
10
  id: id,
11
11
  kind: :collection,
12
12
  name: name,
13
13
  path: (path || name),
14
- dependencies: [source_dependency],
14
+ dependencies: [source_dependency, *context_dependencies],
15
15
  metadata: metadata
16
16
  )
17
17
 
@@ -19,6 +19,12 @@ module Igniter
19
19
  @contract_class = contract_class
20
20
  @key_name = key_name.to_sym
21
21
  @mode = mode.to_sym
22
+ @context_dependencies = Array(context_dependencies).map(&:to_sym)
23
+ @input_mapper = input_mapper
24
+ end
25
+
26
+ def input_mapper?
27
+ !input_mapper.nil?
22
28
  end
23
29
  end
24
30
  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,8 @@ 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"
11
13
 
12
14
  module Igniter
13
15
  module Model
@@ -11,10 +11,10 @@ module Igniter
11
11
  @runner_strategy = runner
12
12
  @max_workers = max_workers
13
13
  @store = store
14
- @input_validator = InputValidator.new(compiled_graph)
14
+ @events = Events::Bus.new
15
+ @input_validator = InputValidator.new(compiled_graph, execution_id: @events.execution_id)
15
16
  @inputs = @input_validator.normalize_initial_inputs(inputs)
16
17
  @cache = Cache.new
17
- @events = Events::Bus.new
18
18
  @audit = Extensions::Auditing::Timeline.new(self)
19
19
  @events.subscribe(@audit)
20
20
  @resolver = Resolver.new(self)
@@ -3,8 +3,9 @@
3
3
  module Igniter
4
4
  module Runtime
5
5
  class InputValidator
6
- def initialize(compiled_graph)
6
+ def initialize(compiled_graph, execution_id: nil)
7
7
  @compiled_graph = compiled_graph
8
+ @execution_id = execution_id
8
9
  end
9
10
 
10
11
  def normalize_initial_inputs(raw_inputs)
@@ -106,7 +107,7 @@ module Igniter
106
107
  hash.each_with_object({}) { |(key, value), memo| memo[key.to_sym] = value }
107
108
  end
108
109
 
109
- def input_error(input_node, message)
110
+ def input_error(input_node, message) # rubocop:disable Metrics/MethodLength
110
111
  InputError.new(
111
112
  message,
112
113
  context: {
@@ -114,7 +115,8 @@ module Igniter
114
115
  node_id: input_node.id,
115
116
  node_name: input_node.name,
116
117
  node_path: input_node.path,
117
- source_location: input_node.source_location
118
+ source_location: input_node.source_location,
119
+ execution_id: @execution_id
118
120
  }
119
121
  )
120
122
  end
@@ -25,6 +25,10 @@ module Igniter
25
25
  resolve_branch(node)
26
26
  when :collection
27
27
  resolve_collection(node)
28
+ when :await
29
+ resolve_await(node)
30
+ when :remote
31
+ resolve_remote(node)
28
32
  else
29
33
  raise ResolutionError, "Unsupported node kind: #{node.kind}"
30
34
  end
@@ -59,6 +63,43 @@ module Igniter
59
63
  NodeState.new(node: node, status: :succeeded, value: @execution.fetch_input!(node.name))
60
64
  end
61
65
 
66
+ def resolve_await(node)
67
+ deferred = Runtime::DeferredResult.build(
68
+ payload: { event: node.event_name },
69
+ source_node: node.name,
70
+ waiting_on: node.name
71
+ )
72
+ raise PendingDependencyError.new(deferred, "Waiting for external event '#{node.event_name}'")
73
+ end
74
+
75
+ def resolve_remote(node) # rubocop:disable Metrics/MethodLength
76
+ unless defined?(Igniter::Server::Client)
77
+ raise ResolutionError,
78
+ "remote: nodes require `require 'igniter/server'` (server integration not loaded)"
79
+ end
80
+
81
+ inputs = node.input_mapping.each_with_object({}) do |(child_input, dep_name), memo|
82
+ memo[child_input] = resolve_dependency_value(dep_name)
83
+ end
84
+
85
+ client = Igniter::Server::Client.new(node.node_url, timeout: node.timeout)
86
+ response = client.execute(node.contract_name, inputs: inputs)
87
+
88
+ case response[:status]
89
+ when :succeeded
90
+ NodeState.new(node: node, status: :succeeded, value: response[:outputs])
91
+ when :failed
92
+ error_message = response.dig(:error, :message) || response.dig(:error, "message")
93
+ raise ResolutionError,
94
+ "Remote #{node.contract_name}@#{node.node_url}: #{error_message}"
95
+ else
96
+ raise ResolutionError,
97
+ "Remote #{node.contract_name}@#{node.node_url}: unexpected status '#{response[:status]}'"
98
+ end
99
+ rescue Igniter::Server::Client::ConnectionError => e
100
+ raise ResolutionError, "Cannot reach #{node.node_url}: #{e.message}"
101
+ end
102
+
62
103
  def resolve_compute(node)
63
104
  dependencies = node.dependencies.each_with_object({}) do |dependency_name, memo|
64
105
  memo[dependency_name] = resolve_dependency_value(dependency_name)
@@ -123,10 +164,18 @@ module Igniter
123
164
 
124
165
  raise BranchSelectionError, "Branch '#{node.name}' has no matching case and no default" unless selected_contract
125
166
 
126
- child_inputs = node.input_mapping.each_with_object({}) do |(child_input_name, dependency_name), memo|
127
- memo[child_input_name] = resolve_dependency_value(dependency_name)
167
+ context_values = node.context_dependencies.each_with_object({}) do |dependency_name, memo|
168
+ memo[dependency_name] = resolve_dependency_value(dependency_name)
128
169
  end
129
170
 
171
+ child_inputs = if node.input_mapper?
172
+ map_branch_inputs(node, selector_value, context_values)
173
+ else
174
+ node.input_mapping.each_with_object({}) do |(child_input_name, dependency_name), memo|
175
+ memo[child_input_name] = resolve_dependency_value(dependency_name)
176
+ end
177
+ end
178
+
130
179
  @execution.events.emit(
131
180
  :branch_selected,
132
181
  node: node,
@@ -147,9 +196,22 @@ module Igniter
147
196
  NodeState.new(node: node, status: :succeeded, value: child_contract.result)
148
197
  end
149
198
 
199
+ def map_branch_inputs(node, selector_value, context_values)
200
+ mapper = node.input_mapper
201
+
202
+ if mapper.is_a?(Symbol) || mapper.is_a?(String)
203
+ return @execution.contract_instance.public_send(mapper, selector: selector_value, **context_values)
204
+ end
205
+
206
+ mapper.call(selector: selector_value, **context_values)
207
+ end
208
+
150
209
  def resolve_collection(node)
151
210
  items = resolve_dependency_value(node.source_dependency)
152
- normalized_items = normalize_collection_items(node, items)
211
+ context_values = node.context_dependencies.each_with_object({}) do |dependency_name, memo|
212
+ memo[dependency_name] = resolve_dependency_value(dependency_name)
213
+ end
214
+ normalized_items = normalize_collection_items(node, items, context_values)
153
215
  collection_items = {}
154
216
 
155
217
  normalized_items.each do |item_inputs|
@@ -289,7 +351,8 @@ module Igniter
289
351
  node_id: node.id,
290
352
  node_name: node.name,
291
353
  node_path: node.path,
292
- source_location: node.source_location
354
+ source_location: node.source_location,
355
+ execution_id: @execution.events.execution_id
293
356
  }
294
357
  )
295
358
  end
@@ -310,7 +373,11 @@ module Igniter
310
373
  )
311
374
  end
312
375
 
313
- def normalize_collection_items(node, items)
376
+ def normalize_collection_items(node, items, context_values = {})
377
+ if node.input_mapper? && items.is_a?(Hash)
378
+ items = items.to_a
379
+ end
380
+
314
381
  unless items.is_a?(Array)
315
382
  raise CollectionInputError.new(
316
383
  "Collection '#{node.name}' expects an array, got #{items.class}",
@@ -318,7 +385,13 @@ module Igniter
318
385
  )
319
386
  end
320
387
 
321
- items.each do |item|
388
+ mapped_items = if node.input_mapper?
389
+ items.map { |item| map_collection_item_inputs(node, item, context_values) }
390
+ else
391
+ items
392
+ end
393
+
394
+ mapped_items.each do |item|
322
395
  next if item.is_a?(Hash)
323
396
 
324
397
  raise CollectionInputError.new(
@@ -327,8 +400,18 @@ module Igniter
327
400
  )
328
401
  end
329
402
 
330
- ensure_unique_collection_keys!(node, items)
331
- items.map { |item| item.transform_keys(&:to_sym) }
403
+ ensure_unique_collection_keys!(node, mapped_items)
404
+ mapped_items.map { |item| item.transform_keys(&:to_sym) }
405
+ end
406
+
407
+ def map_collection_item_inputs(node, item, context_values)
408
+ mapper = node.input_mapper
409
+
410
+ if mapper.is_a?(Symbol) || mapper.is_a?(String)
411
+ return @execution.contract_instance.public_send(mapper, item: item, **context_values)
412
+ end
413
+
414
+ mapper.call(item: item, **context_values)
332
415
  end
333
416
 
334
417
  def extract_collection_key(node, item_inputs)
@@ -12,7 +12,7 @@ module Igniter
12
12
  @snapshot_column = snapshot_column.to_sym
13
13
  end
14
14
 
15
- def save(snapshot)
15
+ def save(snapshot, correlation: nil, graph: nil) # rubocop:disable Lint/UnusedMethodArgument
16
16
  execution_id = snapshot[:execution_id] || snapshot["execution_id"]
17
17
  record = @record_class.find_or_initialize_by(@execution_id_column => execution_id)
18
18
  record.public_send(:"#{@snapshot_column}=", JSON.generate(snapshot))
@@ -20,6 +20,18 @@ module Igniter
20
20
  execution_id
21
21
  end
22
22
 
23
+ def find_by_correlation(graph:, correlation:)
24
+ raise NotImplementedError, "find_by_correlation is not implemented for ActiveRecordStore"
25
+ end
26
+
27
+ def list_all(graph: nil)
28
+ raise NotImplementedError, "list_all is not implemented for ActiveRecordStore"
29
+ end
30
+
31
+ def list_pending(graph: nil)
32
+ raise NotImplementedError, "list_pending is not implemented for ActiveRecordStore"
33
+ end
34
+
23
35
  def fetch(execution_id)
24
36
  record = @record_class.find_by(@execution_id_column => execution_id)
25
37
  raise Igniter::ResolutionError, "No execution snapshot found for '#{execution_id}'" unless record
@@ -12,12 +12,51 @@ module Igniter
12
12
  FileUtils.mkdir_p(@root)
13
13
  end
14
14
 
15
- def save(snapshot)
15
+ def save(snapshot, correlation: nil, graph: nil)
16
16
  execution_id = snapshot[:execution_id] || snapshot["execution_id"]
17
- File.write(path_for(execution_id), JSON.pretty_generate(snapshot))
17
+ data = snapshot.merge(
18
+ _graph: graph,
19
+ _correlation: correlation&.transform_keys(&:to_s)
20
+ ).compact
21
+ File.write(path_for(execution_id), JSON.pretty_generate(data))
18
22
  execution_id
19
23
  end
20
24
 
25
+ def find_by_correlation(graph:, correlation:)
26
+ normalized = correlation.transform_keys(&:to_s)
27
+ each_snapshot do |data|
28
+ next unless data["_graph"] == graph
29
+
30
+ stored_corr = data["_correlation"] || {}
31
+ return data["execution_id"] if stored_corr == normalized
32
+ end
33
+ nil
34
+ end
35
+
36
+ def list_all(graph: nil)
37
+ results = []
38
+ each_snapshot do |data|
39
+ next if graph && data["_graph"] != graph
40
+
41
+ results << data["execution_id"]
42
+ end
43
+ results
44
+ end
45
+
46
+ def list_pending(graph: nil)
47
+ results = []
48
+ each_snapshot do |data|
49
+ next if graph && data["_graph"] != graph
50
+
51
+ states = data["states"] || {}
52
+ pending = states.any? do |_name, state|
53
+ (state["status"] || state[:status]).to_s == "pending"
54
+ end
55
+ results << data["execution_id"] if pending
56
+ end
57
+ results
58
+ end
59
+
21
60
  def fetch(execution_id)
22
61
  JSON.parse(File.read(path_for(execution_id)))
23
62
  rescue Errno::ENOENT
@@ -37,6 +76,15 @@ module Igniter
37
76
  def path_for(execution_id)
38
77
  File.join(@root, "#{execution_id}.json")
39
78
  end
79
+
80
+ def each_snapshot(&block)
81
+ Dir.glob(File.join(@root, "*.json")).each do |file|
82
+ data = JSON.parse(File.read(file))
83
+ block.call(data)
84
+ rescue JSON::ParserError
85
+ next
86
+ end
87
+ end
40
88
  end
41
89
  end
42
90
  end
@@ -6,15 +6,68 @@ module Igniter
6
6
  class MemoryStore
7
7
  def initialize
8
8
  @snapshots = {}
9
+ @correlation_index = {}
9
10
  @mutex = Mutex.new
10
11
  end
11
12
 
12
- def save(snapshot)
13
+ def save(snapshot, correlation: nil, graph: nil) # rubocop:disable Metrics/MethodLength
13
14
  execution_id = snapshot[:execution_id] || snapshot["execution_id"]
14
- @mutex.synchronize { @snapshots[execution_id] = deep_copy(snapshot) }
15
+ @mutex.synchronize do
16
+ @snapshots[execution_id] = deep_copy(snapshot)
17
+ if graph
18
+ @correlation_index[execution_id] = {
19
+ graph: graph,
20
+ correlation: (correlation || {}).transform_keys(&:to_sym)
21
+ }
22
+ end
23
+ end
15
24
  execution_id
16
25
  end
17
26
 
27
+ def find_by_correlation(graph:, correlation:)
28
+ normalized = correlation.transform_keys(&:to_sym)
29
+ @mutex.synchronize do
30
+ @correlation_index.each do |execution_id, entry|
31
+ next unless entry[:graph] == graph
32
+ return execution_id if entry[:correlation] == normalized
33
+ end
34
+ nil
35
+ end
36
+ end
37
+
38
+ def list_all(graph: nil)
39
+ @mutex.synchronize do
40
+ if graph
41
+ @correlation_index.select { |_id, entry| entry[:graph] == graph }.keys
42
+ else
43
+ @snapshots.keys
44
+ end
45
+ end
46
+ end
47
+
48
+ def list_pending(graph: nil) # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
49
+ ids = @mutex.synchronize do
50
+ if graph
51
+ @correlation_index.select { |_id, entry| entry[:graph] == graph }.keys
52
+ else
53
+ @snapshots.keys
54
+ end
55
+ end
56
+
57
+ @mutex.synchronize do
58
+ ids.select do |id|
59
+ snapshot = @snapshots[id]
60
+ next false unless snapshot
61
+
62
+ states = snapshot[:states] || snapshot["states"] || {}
63
+ states.any? do |_name, state|
64
+ status = state[:status] || state["status"]
65
+ status.to_s == "pending"
66
+ end
67
+ end
68
+ end
69
+ end
70
+
18
71
  def fetch(execution_id)
19
72
  @mutex.synchronize { deep_copy(@snapshots.fetch(execution_id)) }
20
73
  rescue KeyError
@@ -11,12 +11,24 @@ module Igniter
11
11
  @namespace = namespace
12
12
  end
13
13
 
14
- def save(snapshot)
14
+ def save(snapshot, correlation: nil, graph: nil) # rubocop:disable Lint/UnusedMethodArgument
15
15
  execution_id = snapshot[:execution_id] || snapshot["execution_id"]
16
16
  @redis.set(redis_key(execution_id), JSON.generate(snapshot))
17
17
  execution_id
18
18
  end
19
19
 
20
+ def find_by_correlation(graph:, correlation:)
21
+ raise NotImplementedError, "find_by_correlation is not implemented for RedisStore"
22
+ end
23
+
24
+ def list_all(graph: nil)
25
+ raise NotImplementedError, "list_all is not implemented for RedisStore"
26
+ end
27
+
28
+ def list_pending(graph: nil)
29
+ raise NotImplementedError, "list_pending is not implemented for RedisStore"
30
+ end
31
+
20
32
  def fetch(execution_id)
21
33
  payload = @redis.get(redis_key(execution_id))
22
34
  raise Igniter::ResolutionError, "No execution snapshot found for '#{execution_id}'" unless payload
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Igniter
8
+ module Server
9
+ # HTTP client for calling remote igniter-server nodes.
10
+ # Uses only stdlib (Net::HTTP + JSON), no external gems required.
11
+ class Client
12
+ class Error < Igniter::Server::Error; end
13
+ class ConnectionError < Error; end
14
+ class RemoteError < Error; end
15
+
16
+ def initialize(base_url, timeout: 30)
17
+ @base_url = base_url.chomp("/")
18
+ @timeout = timeout
19
+ end
20
+
21
+ # Execute a contract on the remote node synchronously.
22
+ #
23
+ # Returns a symbolized hash:
24
+ # { status: :succeeded, execution_id: "uuid", outputs: { result: 42 } }
25
+ # { status: :failed, execution_id: "uuid", error: { message: "..." } }
26
+ # { status: :pending, execution_id: "uuid", waiting_for: ["event"] }
27
+ def execute(contract_name, inputs: {})
28
+ response = post(
29
+ "/v1/contracts/#{uri_encode(contract_name)}/execute",
30
+ { inputs: inputs }
31
+ )
32
+ symbolize_response(response)
33
+ end
34
+
35
+ # Deliver an event to a pending distributed workflow on the remote node.
36
+ def deliver_event(contract_name, event:, correlation:, payload: {})
37
+ response = post(
38
+ "/v1/contracts/#{uri_encode(contract_name)}/events",
39
+ { event: event, correlation: correlation, payload: payload }
40
+ )
41
+ symbolize_response(response)
42
+ end
43
+
44
+ # Fetch execution status by ID.
45
+ def status(execution_id)
46
+ symbolize_response(get("/v1/executions/#{uri_encode(execution_id)}"))
47
+ end
48
+
49
+ # Check remote node health.
50
+ def health
51
+ get("/v1/health")
52
+ end
53
+
54
+ private
55
+
56
+ def post(path, body)
57
+ uri = build_uri(path)
58
+ http = build_http(uri)
59
+ req = Net::HTTP::Post.new(uri.path, json_headers)
60
+ req.body = JSON.generate(body)
61
+ parse_response(http.request(req))
62
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e
63
+ raise ConnectionError, "Cannot connect to #{@base_url}: #{e.message}"
64
+ end
65
+
66
+ def get(path)
67
+ uri = build_uri(path)
68
+ http = build_http(uri)
69
+ req = Net::HTTP::Get.new(uri.path, json_headers)
70
+ parse_response(http.request(req))
71
+ rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e
72
+ raise ConnectionError, "Cannot connect to #{@base_url}: #{e.message}"
73
+ end
74
+
75
+ def build_uri(path)
76
+ URI.parse("#{@base_url}#{path}")
77
+ end
78
+
79
+ def build_http(uri)
80
+ http = Net::HTTP.new(uri.host, uri.port)
81
+ http.use_ssl = uri.scheme == "https"
82
+ http.read_timeout = @timeout
83
+ http.open_timeout = 10
84
+ http
85
+ end
86
+
87
+ def json_headers
88
+ { "Content-Type" => "application/json", "Accept" => "application/json" }
89
+ end
90
+
91
+ def parse_response(response)
92
+ body = begin
93
+ JSON.parse(response.body.to_s)
94
+ rescue JSON::ParserError
95
+ {}
96
+ end
97
+ raise RemoteError, "Remote error #{response.code}: #{body["error"]}" unless response.is_a?(Net::HTTPSuccess)
98
+
99
+ body
100
+ end
101
+
102
+ def symbolize_response(hash)
103
+ {
104
+ status: hash["status"]&.to_sym,
105
+ execution_id: hash["execution_id"],
106
+ outputs: symbolize_keys(hash["outputs"] || {}),
107
+ waiting_for: hash["waiting_for"] || [],
108
+ error: hash["error"]
109
+ }
110
+ end
111
+
112
+ def symbolize_keys(hash)
113
+ return hash unless hash.is_a?(Hash)
114
+
115
+ hash.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v }
116
+ end
117
+
118
+ def uri_encode(str)
119
+ URI.encode_uri_component(str.to_s)
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Server
5
+ class Config
6
+ attr_accessor :host, :port, :store, :logger
7
+ attr_reader :registry
8
+
9
+ def initialize
10
+ @host = "0.0.0.0"
11
+ @port = 4567
12
+ @store = Igniter::Runtime::Stores::MemoryStore.new
13
+ @registry = Registry.new
14
+ @logger = nil
15
+ end
16
+
17
+ def register(name, contract_class)
18
+ @registry.register(name, contract_class)
19
+ self
20
+ end
21
+
22
+ def contracts=(hash)
23
+ hash.each { |name, klass| register(name.to_s, klass) }
24
+ end
25
+ end
26
+ end
27
+ end