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.
- checksums.yaml +4 -4
- data/README.md +217 -0
- data/docs/APPLICATION_V1.md +253 -0
- data/docs/CAPABILITIES_V1.md +207 -0
- data/docs/CONSENSUS_V1.md +477 -0
- data/docs/CONTENT_ADDRESSING_V1.md +221 -0
- data/docs/DATAFLOW_V1.md +274 -0
- data/docs/MESH_V1.md +732 -0
- data/docs/NODE_CACHE_V1.md +324 -0
- data/docs/PROACTIVE_AGENTS_V1.md +293 -0
- data/docs/SERVER_V1.md +200 -1
- data/docs/SKILLS_V1.md +213 -0
- data/docs/STORE_ADAPTERS.md +41 -13
- data/docs/TEMPORAL_V1.md +174 -0
- data/docs/TOOLS_V1.md +347 -0
- data/docs/TRANSCRIPTION_V1.md +403 -0
- data/examples/README.md +37 -0
- data/examples/consensus.rb +239 -0
- data/examples/dataflow.rb +308 -0
- data/examples/elocal_webhook.rb +1 -0
- data/examples/llm_tools.rb +237 -0
- data/examples/mesh.rb +239 -0
- data/examples/mesh_discovery.rb +267 -0
- data/examples/mesh_gossip.rb +162 -0
- data/examples/ringcentral_routing.rb +1 -1
- data/lib/igniter/agents/ai/alert_agent.rb +111 -0
- data/lib/igniter/agents/ai/chain_agent.rb +127 -0
- data/lib/igniter/agents/ai/critic_agent.rb +163 -0
- data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
- data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
- data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
- data/lib/igniter/agents/ai/observer_agent.rb +184 -0
- data/lib/igniter/agents/ai/planner_agent.rb +210 -0
- data/lib/igniter/agents/ai/router_agent.rb +131 -0
- data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
- data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
- data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
- data/lib/igniter/agents/proactive_agent.rb +208 -0
- data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
- data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
- data/lib/igniter/agents.rb +56 -0
- data/lib/igniter/application/app_config.rb +32 -0
- data/lib/igniter/application/autoloader.rb +18 -0
- data/lib/igniter/application/generator.rb +157 -0
- data/lib/igniter/application/scheduler.rb +109 -0
- data/lib/igniter/application/yml_loader.rb +39 -0
- data/lib/igniter/application.rb +174 -0
- data/lib/igniter/capabilities.rb +68 -0
- data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
- data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
- data/lib/igniter/consensus/cluster.rb +183 -0
- data/lib/igniter/consensus/errors.rb +14 -0
- data/lib/igniter/consensus/executors.rb +43 -0
- data/lib/igniter/consensus/node.rb +320 -0
- data/lib/igniter/consensus/read_query.rb +30 -0
- data/lib/igniter/consensus/state_machine.rb +58 -0
- data/lib/igniter/consensus.rb +58 -0
- data/lib/igniter/content_addressing.rb +133 -0
- data/lib/igniter/contract.rb +12 -0
- data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
- data/lib/igniter/dataflow/aggregate_state.rb +77 -0
- data/lib/igniter/dataflow/diff.rb +37 -0
- data/lib/igniter/dataflow/diff_state.rb +81 -0
- data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
- data/lib/igniter/dataflow/window_filter.rb +48 -0
- data/lib/igniter/dataflow.rb +65 -0
- data/lib/igniter/dsl/contract_builder.rb +71 -7
- data/lib/igniter/executor.rb +60 -0
- data/lib/igniter/extensions/capabilities.rb +39 -0
- data/lib/igniter/extensions/content_addressing.rb +5 -0
- data/lib/igniter/extensions/dataflow.rb +117 -0
- data/lib/igniter/extensions/mesh.rb +31 -0
- data/lib/igniter/fingerprint.rb +43 -0
- data/lib/igniter/integrations/llm/config.rb +48 -4
- data/lib/igniter/integrations/llm/executor.rb +221 -28
- data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
- data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
- data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
- data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
- data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
- data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
- data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
- data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
- data/lib/igniter/integrations/llm.rb +37 -1
- data/lib/igniter/memory/agent_memory.rb +104 -0
- data/lib/igniter/memory/episode.rb +29 -0
- data/lib/igniter/memory/fact.rb +27 -0
- data/lib/igniter/memory/memorable.rb +90 -0
- data/lib/igniter/memory/reflection_cycle.rb +96 -0
- data/lib/igniter/memory/reflection_record.rb +28 -0
- data/lib/igniter/memory/store.rb +115 -0
- data/lib/igniter/memory/stores/in_memory.rb +136 -0
- data/lib/igniter/memory/stores/sqlite.rb +284 -0
- data/lib/igniter/memory.rb +80 -0
- data/lib/igniter/mesh/announcer.rb +55 -0
- data/lib/igniter/mesh/config.rb +45 -0
- data/lib/igniter/mesh/discovery.rb +39 -0
- data/lib/igniter/mesh/errors.rb +31 -0
- data/lib/igniter/mesh/gossip.rb +47 -0
- data/lib/igniter/mesh/peer.rb +21 -0
- data/lib/igniter/mesh/peer_registry.rb +51 -0
- data/lib/igniter/mesh/poller.rb +77 -0
- data/lib/igniter/mesh/router.rb +109 -0
- data/lib/igniter/mesh.rb +85 -0
- data/lib/igniter/metrics/collector.rb +131 -0
- data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
- data/lib/igniter/metrics/snapshot.rb +8 -0
- data/lib/igniter/metrics.rb +37 -0
- data/lib/igniter/model/aggregate_node.rb +34 -0
- data/lib/igniter/model/collection_node.rb +3 -2
- data/lib/igniter/model/compute_node.rb +13 -0
- data/lib/igniter/model/remote_node.rb +18 -2
- data/lib/igniter/node_cache.rb +231 -0
- data/lib/igniter/replication/bootstrapper.rb +61 -0
- data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
- data/lib/igniter/replication/bootstrappers/git.rb +39 -0
- data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
- data/lib/igniter/replication/expansion_plan.rb +38 -0
- data/lib/igniter/replication/expansion_planner.rb +142 -0
- data/lib/igniter/replication/manifest.rb +45 -0
- data/lib/igniter/replication/network_topology.rb +123 -0
- data/lib/igniter/replication/node_role.rb +42 -0
- data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
- data/lib/igniter/replication/replication_agent.rb +87 -0
- data/lib/igniter/replication/role_registry.rb +73 -0
- data/lib/igniter/replication/ssh_session.rb +77 -0
- data/lib/igniter/replication.rb +54 -0
- data/lib/igniter/runtime/execution.rb +18 -0
- data/lib/igniter/runtime/input_validator.rb +6 -2
- data/lib/igniter/runtime/resolver.rb +254 -16
- data/lib/igniter/runtime/stores/redis_store.rb +41 -4
- data/lib/igniter/server/client.rb +44 -1
- data/lib/igniter/server/config.rb +13 -6
- data/lib/igniter/server/handlers/event_handler.rb +4 -0
- data/lib/igniter/server/handlers/execute_handler.rb +6 -0
- data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
- data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
- data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
- data/lib/igniter/server/handlers/peers_handler.rb +115 -0
- data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
- data/lib/igniter/server/http_server.rb +54 -17
- data/lib/igniter/server/router.rb +54 -21
- data/lib/igniter/server/server_logger.rb +52 -0
- data/lib/igniter/server.rb +6 -0
- data/lib/igniter/skill/feedback.rb +116 -0
- data/lib/igniter/skill/output_schema.rb +110 -0
- data/lib/igniter/skill.rb +218 -0
- data/lib/igniter/temporal.rb +84 -0
- data/lib/igniter/tool/discoverable.rb +151 -0
- data/lib/igniter/tool.rb +52 -0
- data/lib/igniter/tool_registry.rb +144 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +17 -0
- 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
|
-
|
|
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,
|