igniter 0.4.5 → 0.5.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 (154) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +217 -0
  3. data/docs/APPLICATION_V1.md +253 -0
  4. data/docs/CAPABILITIES_V1.md +207 -0
  5. data/docs/CONSENSUS_V1.md +477 -0
  6. data/docs/CONTENT_ADDRESSING_V1.md +221 -0
  7. data/docs/DATAFLOW_V1.md +274 -0
  8. data/docs/MESH_V1.md +732 -0
  9. data/docs/NODE_CACHE_V1.md +324 -0
  10. data/docs/PROACTIVE_AGENTS_V1.md +293 -0
  11. data/docs/SERVER_V1.md +200 -1
  12. data/docs/SKILLS_V1.md +213 -0
  13. data/docs/STORE_ADAPTERS.md +41 -13
  14. data/docs/TEMPORAL_V1.md +174 -0
  15. data/docs/TOOLS_V1.md +347 -0
  16. data/docs/TRANSCRIPTION_V1.md +403 -0
  17. data/examples/README.md +37 -0
  18. data/examples/consensus.rb +239 -0
  19. data/examples/dataflow.rb +308 -0
  20. data/examples/elocal_webhook.rb +1 -0
  21. data/examples/llm_tools.rb +237 -0
  22. data/examples/mesh.rb +239 -0
  23. data/examples/mesh_discovery.rb +267 -0
  24. data/examples/mesh_gossip.rb +162 -0
  25. data/examples/ringcentral_routing.rb +1 -1
  26. data/lib/igniter/agents/ai/alert_agent.rb +111 -0
  27. data/lib/igniter/agents/ai/chain_agent.rb +127 -0
  28. data/lib/igniter/agents/ai/critic_agent.rb +163 -0
  29. data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
  30. data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
  31. data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
  32. data/lib/igniter/agents/ai/observer_agent.rb +184 -0
  33. data/lib/igniter/agents/ai/planner_agent.rb +210 -0
  34. data/lib/igniter/agents/ai/router_agent.rb +131 -0
  35. data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
  36. data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
  37. data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
  38. data/lib/igniter/agents/proactive_agent.rb +208 -0
  39. data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
  40. data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
  41. data/lib/igniter/agents.rb +56 -0
  42. data/lib/igniter/application/app_config.rb +32 -0
  43. data/lib/igniter/application/autoloader.rb +18 -0
  44. data/lib/igniter/application/generator.rb +157 -0
  45. data/lib/igniter/application/scheduler.rb +109 -0
  46. data/lib/igniter/application/yml_loader.rb +39 -0
  47. data/lib/igniter/application.rb +174 -0
  48. data/lib/igniter/capabilities.rb +68 -0
  49. data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
  50. data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
  51. data/lib/igniter/consensus/cluster.rb +183 -0
  52. data/lib/igniter/consensus/errors.rb +14 -0
  53. data/lib/igniter/consensus/executors.rb +43 -0
  54. data/lib/igniter/consensus/node.rb +320 -0
  55. data/lib/igniter/consensus/read_query.rb +30 -0
  56. data/lib/igniter/consensus/state_machine.rb +58 -0
  57. data/lib/igniter/consensus.rb +58 -0
  58. data/lib/igniter/content_addressing.rb +133 -0
  59. data/lib/igniter/contract.rb +12 -0
  60. data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
  61. data/lib/igniter/dataflow/aggregate_state.rb +77 -0
  62. data/lib/igniter/dataflow/diff.rb +37 -0
  63. data/lib/igniter/dataflow/diff_state.rb +81 -0
  64. data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
  65. data/lib/igniter/dataflow/window_filter.rb +48 -0
  66. data/lib/igniter/dataflow.rb +65 -0
  67. data/lib/igniter/dsl/contract_builder.rb +71 -7
  68. data/lib/igniter/executor.rb +60 -0
  69. data/lib/igniter/extensions/capabilities.rb +39 -0
  70. data/lib/igniter/extensions/content_addressing.rb +5 -0
  71. data/lib/igniter/extensions/dataflow.rb +117 -0
  72. data/lib/igniter/extensions/mesh.rb +31 -0
  73. data/lib/igniter/fingerprint.rb +43 -0
  74. data/lib/igniter/integrations/llm/config.rb +48 -4
  75. data/lib/igniter/integrations/llm/executor.rb +221 -28
  76. data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
  77. data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
  78. data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
  79. data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
  80. data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
  81. data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
  82. data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
  83. data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
  84. data/lib/igniter/integrations/llm.rb +37 -1
  85. data/lib/igniter/memory/agent_memory.rb +104 -0
  86. data/lib/igniter/memory/episode.rb +29 -0
  87. data/lib/igniter/memory/fact.rb +27 -0
  88. data/lib/igniter/memory/memorable.rb +90 -0
  89. data/lib/igniter/memory/reflection_cycle.rb +96 -0
  90. data/lib/igniter/memory/reflection_record.rb +28 -0
  91. data/lib/igniter/memory/store.rb +115 -0
  92. data/lib/igniter/memory/stores/in_memory.rb +136 -0
  93. data/lib/igniter/memory/stores/sqlite.rb +284 -0
  94. data/lib/igniter/memory.rb +80 -0
  95. data/lib/igniter/mesh/announcer.rb +55 -0
  96. data/lib/igniter/mesh/config.rb +45 -0
  97. data/lib/igniter/mesh/discovery.rb +39 -0
  98. data/lib/igniter/mesh/errors.rb +31 -0
  99. data/lib/igniter/mesh/gossip.rb +47 -0
  100. data/lib/igniter/mesh/peer.rb +21 -0
  101. data/lib/igniter/mesh/peer_registry.rb +51 -0
  102. data/lib/igniter/mesh/poller.rb +77 -0
  103. data/lib/igniter/mesh/router.rb +109 -0
  104. data/lib/igniter/mesh.rb +85 -0
  105. data/lib/igniter/metrics/collector.rb +131 -0
  106. data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
  107. data/lib/igniter/metrics/snapshot.rb +8 -0
  108. data/lib/igniter/metrics.rb +37 -0
  109. data/lib/igniter/model/aggregate_node.rb +34 -0
  110. data/lib/igniter/model/collection_node.rb +3 -2
  111. data/lib/igniter/model/compute_node.rb +13 -0
  112. data/lib/igniter/model/remote_node.rb +18 -2
  113. data/lib/igniter/node_cache.rb +231 -0
  114. data/lib/igniter/replication/bootstrapper.rb +61 -0
  115. data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
  116. data/lib/igniter/replication/bootstrappers/git.rb +39 -0
  117. data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
  118. data/lib/igniter/replication/expansion_plan.rb +38 -0
  119. data/lib/igniter/replication/expansion_planner.rb +142 -0
  120. data/lib/igniter/replication/manifest.rb +45 -0
  121. data/lib/igniter/replication/network_topology.rb +123 -0
  122. data/lib/igniter/replication/node_role.rb +42 -0
  123. data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
  124. data/lib/igniter/replication/replication_agent.rb +87 -0
  125. data/lib/igniter/replication/role_registry.rb +73 -0
  126. data/lib/igniter/replication/ssh_session.rb +77 -0
  127. data/lib/igniter/replication.rb +54 -0
  128. data/lib/igniter/runtime/execution.rb +18 -0
  129. data/lib/igniter/runtime/input_validator.rb +6 -2
  130. data/lib/igniter/runtime/resolver.rb +254 -16
  131. data/lib/igniter/runtime/stores/redis_store.rb +41 -4
  132. data/lib/igniter/server/client.rb +44 -1
  133. data/lib/igniter/server/config.rb +13 -6
  134. data/lib/igniter/server/handlers/event_handler.rb +4 -0
  135. data/lib/igniter/server/handlers/execute_handler.rb +6 -0
  136. data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
  137. data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
  138. data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
  139. data/lib/igniter/server/handlers/peers_handler.rb +115 -0
  140. data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
  141. data/lib/igniter/server/http_server.rb +54 -17
  142. data/lib/igniter/server/router.rb +54 -21
  143. data/lib/igniter/server/server_logger.rb +52 -0
  144. data/lib/igniter/server.rb +6 -0
  145. data/lib/igniter/skill/feedback.rb +116 -0
  146. data/lib/igniter/skill/output_schema.rb +110 -0
  147. data/lib/igniter/skill.rb +218 -0
  148. data/lib/igniter/temporal.rb +84 -0
  149. data/lib/igniter/tool/discoverable.rb +151 -0
  150. data/lib/igniter/tool.rb +52 -0
  151. data/lib/igniter/tool_registry.rb +144 -0
  152. data/lib/igniter/version.rb +1 -1
  153. data/lib/igniter.rb +17 -0
  154. metadata +122 -1
data/examples/mesh.rb ADDED
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ─────────────────────────────────────────────────────────────────────────────
4
+ # Igniter Mesh — Phase 1: Static Mesh
5
+ #
6
+ # This example demonstrates how to configure a static peer topology and use
7
+ # the two new routing modes for remote: nodes:
8
+ #
9
+ # capability: :sym — auto-select an alive peer that advertises the capability;
10
+ # if none are alive the node is deferred (:pending), and
11
+ # resolution retries when inputs are updated.
12
+ #
13
+ # pinned_to: "name" — must call this exact peer; if it is down the node
14
+ # fails with IncidentError signalling admin intervention.
15
+ #
16
+ # In production you would require "igniter/extensions/mesh" and configure real
17
+ # peer URLs. Here we stub the HTTP layer so the example runs without any
18
+ # actual servers.
19
+ # ─────────────────────────────────────────────────────────────────────────────
20
+
21
+ require "igniter/extensions/mesh"
22
+ require "json"
23
+
24
+ # ── 1. Configure the mesh ────────────────────────────────────────────────────
25
+
26
+ Igniter::Mesh.configure do |c|
27
+ c.peer_name = "api-node"
28
+ c.local_capabilities = [:api]
29
+
30
+ # Declare the remote peers this node knows about.
31
+ c.add_peer "orders-node",
32
+ url: "http://orders.internal:4567",
33
+ capabilities: %i[orders inventory]
34
+
35
+ c.add_peer "audit-node",
36
+ url: "http://audit.internal:4567",
37
+ capabilities: %i[audit]
38
+ end
39
+
40
+ puts "Mesh configured with #{Igniter::Mesh.config.peers.size} peers:"
41
+ Igniter::Mesh.config.peers.each do |p|
42
+ puts " #{p.name} (#{p.url}) — capabilities: #{p.capabilities.join(", ")}"
43
+ end
44
+ puts
45
+
46
+ # ── 2. Server configuration (what a peer advertises) ────────────────────────
47
+
48
+ Igniter::Server.configure do |c|
49
+ c.peer_name = "orders-node"
50
+ c.peer_capabilities = %i[orders inventory]
51
+ c.register "ProcessOrder", Class.new(Igniter::Contract) do
52
+ input :order_id
53
+ define do
54
+ compute :result, depends_on: :order_id do |order_id:|
55
+ { id: order_id, status: "processed" }
56
+ end
57
+ end
58
+ output :result, from: :result
59
+ end
60
+ end
61
+
62
+ puts "Server peer_name: #{Igniter::Server.config.peer_name}"
63
+ puts "Server capabilities: #{Igniter::Server.config.peer_capabilities.join(", ")}"
64
+ puts
65
+
66
+ # ── 3. Contract using capability: routing ───────────────────────────────────
67
+
68
+ class OrderPipeline < Igniter::Contract
69
+ define do
70
+ input :order_id
71
+ # Route to any alive peer advertising :orders capability.
72
+ # If no peer is alive → node defers (:pending) until one comes up.
73
+ remote :order_result,
74
+ contract: "ProcessOrder",
75
+ capability: :orders,
76
+ inputs: { order_id: :order_id }
77
+ output :order_result
78
+ end
79
+ end
80
+
81
+ # ── 4. Contract using pinned_to: routing ────────────────────────────────────
82
+
83
+ class AuditPipeline < Igniter::Contract
84
+ define do
85
+ input :event
86
+ # Always call the "audit-node" — if it is down, raise IncidentError.
87
+ # This is for critical side-effects that must not be load-balanced.
88
+ remote :audit_log,
89
+ contract: "WriteAudit",
90
+ pinned_to: "audit-node",
91
+ inputs: { event: :event }
92
+ output :audit_log
93
+ end
94
+ end
95
+
96
+ puts "OrderPipeline compiled: #{OrderPipeline.compiled_graph.name}"
97
+ puts "AuditPipeline compiled: #{AuditPipeline.compiled_graph.name}"
98
+ puts
99
+
100
+ # ── 5. Stub HTTP layer and demonstrate capability routing ────────────────────
101
+
102
+ # Simulate orders-node being alive.
103
+ def stub_alive(_url, health_response: { "status" => "ok" })
104
+ client = Object.new
105
+
106
+ client.define_singleton_method(:health) { health_response }
107
+ client.define_singleton_method(:execute) do |_contract, inputs:|
108
+ { status: :succeeded, outputs: { result: { id: inputs[:order_id], status: "processed" } } }
109
+ end
110
+
111
+ # We cannot easily monkey-patch in a plain script, so we demonstrate
112
+ # the behaviour via inline contracts instead.
113
+ client
114
+ end
115
+
116
+ puts "=== Scenario A: capability routing — orders-node alive ==="
117
+
118
+ Igniter::Mesh.reset!
119
+ Igniter::Mesh.configure do |c|
120
+ c.add_peer "orders-node",
121
+ url: "http://orders.internal:4567",
122
+ capabilities: [:orders]
123
+ end
124
+
125
+ # Patch Client for demo purposes
126
+ orders_client = Object.new
127
+ orders_client.define_singleton_method(:health) { { "status" => "ok" } }
128
+ orders_client.define_singleton_method(:execute) do |_contract, inputs:|
129
+ { status: :succeeded, outputs: { result: { id: inputs[:order_id], status: "processed" } } }
130
+ end
131
+
132
+ original_new = Igniter::Server::Client.method(:new)
133
+ Igniter::Server::Client.define_singleton_method(:new) do |url, **opts|
134
+ url == "http://orders.internal:4567" ? orders_client : original_new.call(url, **opts)
135
+ end
136
+
137
+ contract = OrderPipeline.new(order_id: 42)
138
+ begin
139
+ contract.resolve_all
140
+ puts " order_result: #{contract.result.order_result.inspect}"
141
+ rescue Igniter::Error => e
142
+ puts " Error: #{e.message}"
143
+ ensure
144
+ Igniter::Server::Client.define_singleton_method(:new, &original_new)
145
+ Igniter::Mesh.reset!
146
+ end
147
+
148
+ puts
149
+
150
+ puts "=== Scenario B: capability routing — no alive peers (deferred) ==="
151
+
152
+ Igniter::Mesh.reset!
153
+ Igniter::Mesh.configure do |c|
154
+ c.add_peer "orders-node",
155
+ url: "http://orders.internal:4567",
156
+ capabilities: [:orders]
157
+ end
158
+
159
+ dead_client = Object.new
160
+ dead_client.define_singleton_method(:health) do
161
+ raise Igniter::Server::Client::ConnectionError, "Connection refused"
162
+ end
163
+
164
+ Igniter::Server::Client.define_singleton_method(:new) do |url, **opts|
165
+ url == "http://orders.internal:4567" ? dead_client : original_new.call(url, **opts)
166
+ end
167
+
168
+ contract = OrderPipeline.new(order_id: 42)
169
+ begin
170
+ contract.resolve_all
171
+ rescue Igniter::Error
172
+ nil
173
+ end
174
+
175
+ order_state = contract.execution.cache.fetch(:order_result)
176
+ puts " order_result status: #{order_state&.status.inspect} (expected :pending)"
177
+
178
+ Igniter::Server::Client.define_singleton_method(:new, &original_new)
179
+ Igniter::Mesh.reset!
180
+ puts
181
+
182
+ puts "=== Scenario C: pinned_to routing — audit-node down (incident) ==="
183
+
184
+ Igniter::Mesh.reset!
185
+ Igniter::Mesh.configure do |c|
186
+ c.add_peer "audit-node",
187
+ url: "http://audit.internal:4567",
188
+ capabilities: [:audit]
189
+ end
190
+
191
+ Igniter::Server::Client.define_singleton_method(:new) do |url, **opts|
192
+ url == "http://audit.internal:4567" ? dead_client : original_new.call(url, **opts)
193
+ end
194
+
195
+ audit_contract = AuditPipeline.new(event: "order.created")
196
+ begin
197
+ audit_contract.resolve_all
198
+ rescue Igniter::Error
199
+ nil
200
+ end
201
+
202
+ audit_state = audit_contract.execution.cache.fetch(:audit_log)
203
+ puts " audit_log status: #{audit_state&.status.inspect} (expected :failed)"
204
+ puts " error class: #{audit_state&.error&.class}"
205
+ puts " error message: #{audit_state&.error&.message}"
206
+
207
+ Igniter::Server::Client.define_singleton_method(:new, &original_new)
208
+ Igniter::Mesh.reset!
209
+ puts
210
+
211
+ # ── 6. GET /v1/manifest demo ─────────────────────────────────────────────────
212
+
213
+ puts "=== Server manifest endpoint ==="
214
+
215
+ Igniter::Server.configure do |c|
216
+ c.peer_name = "orders-node"
217
+ c.peer_capabilities = %i[orders inventory]
218
+ end
219
+
220
+ registry = Igniter::Server::Registry.new
221
+ registry.register("ProcessOrder", Class.new(Igniter::Contract))
222
+
223
+ handler = Igniter::Server::Handlers::ManifestHandler.new(
224
+ registry,
225
+ Igniter::Runtime::Stores::MemoryStore.new,
226
+ config: Igniter::Server.config
227
+ )
228
+
229
+ result = handler.call(params: {}, body: {})
230
+ manifest = JSON.parse(result[:body])
231
+ puts " peer_name: #{manifest["peer_name"]}"
232
+ puts " capabilities: #{manifest["capabilities"].join(", ")}"
233
+ puts " contracts: #{manifest["contracts"].join(", ")}"
234
+ puts " url: #{manifest["url"]}"
235
+ puts
236
+
237
+ Igniter::Server.reset!
238
+
239
+ puts "Done."
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Igniter Mesh — Phase 2: Dynamic Discovery
4
+ #
5
+ # Demonstrates how peers self-register at startup and how the local node
6
+ # discovers topology without a static add_peer list.
7
+ #
8
+ # This example uses in-process stubs so no real HTTP servers are needed.
9
+
10
+ require_relative "../lib/igniter/extensions/mesh"
11
+
12
+ # ─── Shared contract ──────────────────────────────────────────────────────────
13
+ class ProcessOrder < Igniter::Contract
14
+ define do
15
+ input :order_id
16
+ compute :status, depends_on: :order_id do |order_id:|
17
+ order_id > 0 ? "accepted" : "rejected"
18
+ end
19
+ output :status
20
+ end
21
+ end
22
+
23
+ # ─── Stub HTTP layer ──────────────────────────────────────────────────────────
24
+ # In production each peer is a separate igniter-server process. Here we stub
25
+ # the HTTP client so the example runs inline.
26
+
27
+ module ClientStubs
28
+ PEERS = {
29
+ "http://orders-node-1:4567" => {
30
+ peer_name: "orders-node-1",
31
+ capabilities: %i[orders inventory],
32
+ contracts: ["ProcessOrder"],
33
+ alive: true
34
+ },
35
+ "http://orders-node-2:4567" => {
36
+ peer_name: "orders-node-2",
37
+ capabilities: %i[orders],
38
+ contracts: ["ProcessOrder"],
39
+ alive: true
40
+ },
41
+ "http://audit-node:4567" => {
42
+ peer_name: "audit-node",
43
+ capabilities: %i[audit],
44
+ contracts: ["WriteAudit"],
45
+ alive: false # <-- offline
46
+ },
47
+ "http://seed:4567" => {
48
+ peer_name: "seed",
49
+ capabilities: [],
50
+ contracts: [],
51
+ alive: true,
52
+ # What the seed returns for GET /v1/mesh/peers:
53
+ known_peers: [
54
+ { name: "orders-node-1", url: "http://orders-node-1:4567", capabilities: ["orders", "inventory"] },
55
+ { name: "orders-node-2", url: "http://orders-node-2:4567", capabilities: ["orders"] },
56
+ { name: "audit-node", url: "http://audit-node:4567", capabilities: ["audit"] }
57
+ ]
58
+ }
59
+ }.freeze
60
+
61
+ def self.install!
62
+ Igniter::Server::Client.class_eval do
63
+ alias_method :real_health, :health
64
+ alias_method :real_manifest, :manifest
65
+ alias_method :real_list_peers, :list_peers
66
+ alias_method :real_register_peer, :register_peer
67
+
68
+ def health
69
+ info = ClientStubs::PEERS[@base_url]
70
+ raise Igniter::Server::Client::ConnectionError, "offline" unless info && info[:alive]
71
+
72
+ { "status" => "ok" }
73
+ end
74
+
75
+ def manifest
76
+ info = ClientStubs::PEERS[@base_url] || {}
77
+ { peer_name: info[:peer_name], capabilities: info[:capabilities] || [],
78
+ contracts: info[:contracts] || [], url: @base_url }
79
+ end
80
+
81
+ def list_peers
82
+ info = ClientStubs::PEERS[@base_url] || {}
83
+ (info[:known_peers] || []).map do |p|
84
+ { name: p[:name], url: p[:url], capabilities: Array(p[:capabilities]).map(&:to_sym) }
85
+ end
86
+ end
87
+
88
+ def register_peer(name:, url:, capabilities: [])
89
+ puts " [seed] registered peer: #{name} @ #{url} caps=#{capabilities}"
90
+ { "registered" => true }
91
+ end
92
+ end
93
+ end
94
+
95
+ def self.uninstall!
96
+ Igniter::Server::Client.class_eval do
97
+ alias_method :health, :real_health
98
+ alias_method :manifest, :real_manifest
99
+ alias_method :list_peers, :real_list_peers
100
+ alias_method :register_peer, :real_register_peer
101
+ end
102
+ end
103
+ end
104
+
105
+ ClientStubs.install!
106
+
107
+ # ─── Stub execute path ────────────────────────────────────────────────────────
108
+ Igniter::Server::Client.class_eval do
109
+ alias_method :real_execute, :execute
110
+ def execute(contract_name, inputs: {})
111
+ order_id = inputs[:order_id] || inputs["order_id"] || 0
112
+ contract = ProcessOrder.new(order_id: order_id)
113
+ contract.resolve_all
114
+ { status: :succeeded, execution_id: "stub-#{order_id}",
115
+ outputs: { status: contract.result.status }, waiting_for: [], error: nil }
116
+ end
117
+ end
118
+
119
+ # ─────────────────────────────────────────────────────────────────────────────
120
+ # Scenario 1 — Dynamic topology discovery at startup
121
+ # ─────────────────────────────────────────────────────────────────────────────
122
+ puts "=" * 60
123
+ puts "Scenario 1: Dynamic discovery from seed"
124
+ puts "=" * 60
125
+
126
+ Igniter::Mesh.configure do |c|
127
+ c.peer_name = "api-node"
128
+ c.local_url = "http://api-node:4567"
129
+ c.local_capabilities = %i[api]
130
+ c.seeds = %w[http://seed:4567]
131
+ c.discovery_interval = 60
132
+ end
133
+
134
+ puts "\nBefore start_discovery!:"
135
+ puts " Dynamic peers: #{Igniter::Mesh.config.peer_registry.size}"
136
+
137
+ Igniter::Mesh.start_discovery!
138
+
139
+ puts "\nAfter start_discovery!:"
140
+ puts " Dynamic peers: #{Igniter::Mesh.config.peer_registry.size}"
141
+ Igniter::Mesh.config.peer_registry.all.each do |p|
142
+ puts " - #{p.name} @ #{p.url} caps=#{p.capabilities}"
143
+ end
144
+ puts
145
+
146
+ # ─────────────────────────────────────────────────────────────────────────────
147
+ # Scenario 2 — Capability routing over discovered peers
148
+ # ─────────────────────────────────────────────────────────────────────────────
149
+ puts "=" * 60
150
+ puts "Scenario 2: Capability routing to discovered peers"
151
+ puts "=" * 60
152
+
153
+ class OrderPipeline < Igniter::Contract
154
+ define do
155
+ input :order_id
156
+
157
+ remote :order_result,
158
+ contract: "ProcessOrder",
159
+ capability: :orders,
160
+ inputs: { order_id: :order_id }
161
+
162
+ output :order_result
163
+ end
164
+ end
165
+
166
+ results = [101, 102, 103].map do |id|
167
+ contract = OrderPipeline.new(order_id: id)
168
+ contract.resolve_all
169
+ status = contract.result.order_result[:status]
170
+ puts " order_id=#{id} → status=#{status}"
171
+ status
172
+ end
173
+
174
+ puts "\nAll orders accepted: #{results.all? { |r| r == "accepted" }}"
175
+ puts
176
+
177
+ # ─────────────────────────────────────────────────────────────────────────────
178
+ # Scenario 3 — New peer joins mid-run (simulated via direct registration)
179
+ # ─────────────────────────────────────────────────────────────────────────────
180
+ puts "=" * 60
181
+ puts "Scenario 3: New peer joins the mesh mid-run"
182
+ puts "=" * 60
183
+
184
+ # Simulate a new billing-node announcing itself via POST /v1/mesh/peers
185
+ # In production this is done by the new peer calling start_discovery!
186
+ new_peer = Igniter::Mesh::Peer.new(
187
+ name: "billing-node",
188
+ url: "http://billing-node:4567",
189
+ capabilities: %i[billing]
190
+ )
191
+ Igniter::Mesh.config.peer_registry.register(new_peer)
192
+
193
+ puts " Registered billing-node into local registry"
194
+ puts " Peers with :billing capability: #{Igniter::Mesh.config.peer_registry.peers_with_capability(:billing).map(&:name)}"
195
+ puts
196
+
197
+ # ─────────────────────────────────────────────────────────────────────────────
198
+ # Scenario 4 — Deferred when discovered peer is offline
199
+ # ─────────────────────────────────────────────────────────────────────────────
200
+ puts "=" * 60
201
+ puts "Scenario 4: Capability routing → :pending when peer offline"
202
+ puts "=" * 60
203
+
204
+ class AuditPipeline < Igniter::Contract
205
+ define do
206
+ input :event
207
+
208
+ remote :audit_log,
209
+ contract: "WriteAudit",
210
+ capability: :audit,
211
+ inputs: { event: :event }
212
+
213
+ output :audit_log
214
+ end
215
+ end
216
+
217
+ contract = AuditPipeline.new(event: "order_placed")
218
+ begin
219
+ contract.resolve_all
220
+ rescue Igniter::Error
221
+ nil
222
+ end
223
+
224
+ cache = contract.execution.cache
225
+ audit_state = cache.values.find { |s| s.node.name == :audit_log }
226
+ puts " audit_log node status: #{audit_state&.status || :unknown}"
227
+ puts " (audit-node is offline → capability routing defers → :pending)"
228
+ puts
229
+
230
+ # ─────────────────────────────────────────────────────────────────────────────
231
+ # Scenario 5 — Static + dynamic peers merged
232
+ # ─────────────────────────────────────────────────────────────────────────────
233
+ puts "=" * 60
234
+ puts "Scenario 5: Static add_peer + dynamic discovery merged"
235
+ puts "=" * 60
236
+
237
+ Igniter::Mesh.stop_discovery!
238
+ Igniter::Mesh.reset!
239
+
240
+ Igniter::Mesh.configure do |c|
241
+ c.peer_name = "api-node"
242
+ c.local_url = "http://api-node:4567"
243
+ c.seeds = %w[http://seed:4567]
244
+ c.discovery_interval = 60
245
+ # Static peer declared manually:
246
+ c.add_peer "legacy-node", url: "http://legacy:4567", capabilities: %i[billing]
247
+ end
248
+
249
+ Igniter::Mesh.start_discovery!
250
+
251
+ static_names = Igniter::Mesh.config.peers.map(&:name)
252
+ dynamic_names = Igniter::Mesh.config.peer_registry.all.map(&:name)
253
+
254
+ puts " Static peers: #{static_names}"
255
+ puts " Dynamic peers: #{dynamic_names}"
256
+ puts " Combined (via router):"
257
+ all_orders = Igniter::Mesh.router.instance_eval { all_capable_peers(:orders) }.map(&:name)
258
+ puts " :orders capable peers: #{all_orders}"
259
+
260
+ # ─────────────────────────────────────────────────────────────────────────────
261
+ # Cleanup
262
+ # ─────────────────────────────────────────────────────────────────────────────
263
+ Igniter::Mesh.stop_discovery!
264
+ Igniter::Mesh.reset!
265
+ ClientStubs.uninstall!
266
+
267
+ puts "\nDone."
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Igniter Mesh — Phase 3: Gossip Protocol
4
+ #
5
+ # Demonstrates how peer topology spreads through gossip even when seeds
6
+ # are unavailable. Three nodes form a mesh: A knows B, C only knows A.
7
+ # After one gossip round C discovers B — without ever contacting a seed.
8
+ #
9
+ # This example uses in-process stubs so no real HTTP servers are needed.
10
+
11
+ require_relative "../lib/igniter/extensions/mesh"
12
+
13
+ # ─── Shared contract ──────────────────────────────────────────────────────────
14
+ class CheckInventory < Igniter::Contract
15
+ define do
16
+ input :sku
17
+ compute :available, depends_on: :sku do |sku:|
18
+ sku.start_with?("VALID") ? true : false
19
+ end
20
+ output :available
21
+ end
22
+ end
23
+
24
+ # ─── Node registries (in-memory representation of three separate processes) ───
25
+ #
26
+ # node_a: knows B (via seed bootstrap)
27
+ # node_b: fresh node with inventory capability
28
+ # node_c: knows A (via seed bootstrap), does NOT know B yet
29
+ #
30
+ # After node_c gossips with node_a → it will learn about node_b.
31
+
32
+ NODE_A = {
33
+ name: "node-a", url: "http://node-a:4567",
34
+ capabilities: %w[orders],
35
+ peers: [
36
+ { name: "node-b", url: "http://node-b:4567", capabilities: ["inventory"] }
37
+ ]
38
+ }.freeze
39
+
40
+ NODE_B = {
41
+ name: "node-b", url: "http://node-b:4567",
42
+ capabilities: %w[inventory],
43
+ peers: []
44
+ }.freeze
45
+
46
+ NODE_C = {
47
+ name: "node-c", url: "http://node-c:4567",
48
+ capabilities: %w[api],
49
+ peers: [
50
+ { name: "node-a", url: "http://node-a:4567", capabilities: ["orders"] }
51
+ ]
52
+ }.freeze
53
+
54
+ # ─── Stub HTTP layer ──────────────────────────────────────────────────────────
55
+ # Stubs GET /v1/mesh/peers (list_peers) for each node URL.
56
+ # No health checks or execute stubs needed for this scenario.
57
+
58
+ PEER_DB = {
59
+ NODE_A[:url] => NODE_A,
60
+ NODE_B[:url] => NODE_B,
61
+ NODE_C[:url] => NODE_C
62
+ }.freeze
63
+
64
+ Igniter::Server::Client.class_eval do
65
+ alias_method :real_list_peers_gossip, :list_peers
66
+
67
+ def list_peers
68
+ info = PEER_DB[@base_url] || {}
69
+ (info[:peers] || []).map do |p|
70
+ { name: p[:name], url: p[:url], capabilities: Array(p[:capabilities]).map(&:to_sym) }
71
+ end
72
+ end
73
+ end
74
+
75
+ # ─────────────────────────────────────────────────────────────────────────────
76
+ # Scenario 1 — Baseline: C's registry before gossip
77
+ # ─────────────────────────────────────────────────────────────────────────────
78
+ puts "=" * 60
79
+ puts "Scenario 1: C's registry before gossip"
80
+ puts "=" * 60
81
+
82
+ Igniter::Mesh.configure do |c|
83
+ c.peer_name = NODE_C[:name]
84
+ c.local_url = NODE_C[:url]
85
+ c.local_capabilities = NODE_C[:capabilities].map(&:to_sym)
86
+ c.gossip_fanout = 3
87
+ c.discovery_interval = 60
88
+ c.seeds = [] # no seeds — gossip only
89
+ end
90
+
91
+ # C initially knows A (as if bootstrapped from a seed earlier)
92
+ Igniter::Mesh.config.peer_registry.register(
93
+ Igniter::Mesh::Peer.new(name: NODE_A[:name], url: NODE_A[:url], capabilities: NODE_A[:capabilities].map(&:to_sym))
94
+ )
95
+
96
+ puts "\nC's registry before gossip:"
97
+ Igniter::Mesh.config.peer_registry.all.each do |p|
98
+ puts " - #{p.name} @ #{p.url} caps=#{p.capabilities}"
99
+ end
100
+
101
+ knows_b_before = !Igniter::Mesh.config.peer_registry.peer_named("node-b").nil?
102
+ puts " C knows node-b? #{knows_b_before}"
103
+ puts
104
+
105
+ # ─────────────────────────────────────────────────────────────────────────────
106
+ # Scenario 2 — Gossip round: C contacts A, learns about B
107
+ # ─────────────────────────────────────────────────────────────────────────────
108
+ puts "=" * 60
109
+ puts "Scenario 2: C runs one gossip round → discovers B via A"
110
+ puts "=" * 60
111
+
112
+ Igniter::Mesh::GossipRound.new(Igniter::Mesh.config).run
113
+
114
+ puts "\nC's registry after gossip:"
115
+ Igniter::Mesh.config.peer_registry.all.each do |p|
116
+ puts " - #{p.name} @ #{p.url} caps=#{p.capabilities}"
117
+ end
118
+
119
+ knows_b_after = !Igniter::Mesh.config.peer_registry.peer_named("node-b").nil?
120
+ puts "\n C knows node-b? #{knows_b_after}"
121
+ puts " Convergence achieved without seed: #{knows_b_after}"
122
+ puts
123
+
124
+ # ─────────────────────────────────────────────────────────────────────────────
125
+ # Scenario 3 — gossip_fanout = 0 disables gossip
126
+ # ─────────────────────────────────────────────────────────────────────────────
127
+ puts "=" * 60
128
+ puts "Scenario 3: gossip_fanout = 0 disables the gossip round"
129
+ puts "=" * 60
130
+
131
+ Igniter::Mesh.reset!
132
+
133
+ Igniter::Mesh.configure do |c|
134
+ c.peer_name = NODE_C[:name]
135
+ c.local_url = NODE_C[:url]
136
+ c.gossip_fanout = 0 # disabled
137
+ c.discovery_interval = 60
138
+ c.seeds = []
139
+ end
140
+
141
+ # Seed registry with A
142
+ Igniter::Mesh.config.peer_registry.register(
143
+ Igniter::Mesh::Peer.new(name: NODE_A[:name], url: NODE_A[:url], capabilities: NODE_A[:capabilities].map(&:to_sym))
144
+ )
145
+
146
+ before_size = Igniter::Mesh.config.peer_registry.size
147
+ Igniter::Mesh::Poller.new(Igniter::Mesh.config).poll_once # no seeds → no seed fetch; gossip disabled
148
+ after_size = Igniter::Mesh.config.peer_registry.size
149
+
150
+ puts " gossip_fanout = 0 → registry size unchanged: #{before_size} → #{after_size}"
151
+ puts
152
+
153
+ # ─────────────────────────────────────────────────────────────────────────────
154
+ # Cleanup
155
+ # ─────────────────────────────────────────────────────────────────────────────
156
+ Igniter::Mesh.reset!
157
+
158
+ Igniter::Server::Client.class_eval do
159
+ alias_method :list_peers, :real_list_peers_gossip
160
+ end
161
+
162
+ puts "Done."
@@ -123,7 +123,7 @@ class CallConnectedContract < Igniter::Contract
123
123
  calls.successes.values.map { |item| item.result.summary }
124
124
  end
125
125
 
126
- aggregate :routing_summary, with: %i[calls call_summaries extension_id telephony_status has_calls] do |calls:, call_summaries:, extension_id:, telephony_status:, has_calls:|
126
+ compute :routing_summary, with: %i[calls call_summaries extension_id telephony_status has_calls] do |calls:, call_summaries:, extension_id:, telephony_status:, has_calls:|
127
127
  has_calls
128
128
  {
129
129
  extension_id: extension_id,