igniter 0.4.3 → 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 (162) 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/incremental.rb +142 -0
  22. data/examples/llm_tools.rb +237 -0
  23. data/examples/mesh.rb +239 -0
  24. data/examples/mesh_discovery.rb +267 -0
  25. data/examples/mesh_gossip.rb +162 -0
  26. data/examples/ringcentral_routing.rb +1 -1
  27. data/lib/igniter/agents/ai/alert_agent.rb +111 -0
  28. data/lib/igniter/agents/ai/chain_agent.rb +127 -0
  29. data/lib/igniter/agents/ai/critic_agent.rb +163 -0
  30. data/lib/igniter/agents/ai/evaluator_agent.rb +193 -0
  31. data/lib/igniter/agents/ai/evolution_agent.rb +286 -0
  32. data/lib/igniter/agents/ai/health_check_agent.rb +122 -0
  33. data/lib/igniter/agents/ai/observer_agent.rb +184 -0
  34. data/lib/igniter/agents/ai/planner_agent.rb +210 -0
  35. data/lib/igniter/agents/ai/router_agent.rb +131 -0
  36. data/lib/igniter/agents/ai/self_reflection_agent.rb +175 -0
  37. data/lib/igniter/agents/observability/metrics_agent.rb +130 -0
  38. data/lib/igniter/agents/pipeline/batch_processor_agent.rb +131 -0
  39. data/lib/igniter/agents/proactive_agent.rb +208 -0
  40. data/lib/igniter/agents/reliability/retry_agent.rb +99 -0
  41. data/lib/igniter/agents/scheduling/cron_agent.rb +110 -0
  42. data/lib/igniter/agents.rb +56 -0
  43. data/lib/igniter/application/app_config.rb +32 -0
  44. data/lib/igniter/application/autoloader.rb +18 -0
  45. data/lib/igniter/application/generator.rb +157 -0
  46. data/lib/igniter/application/scheduler.rb +109 -0
  47. data/lib/igniter/application/yml_loader.rb +39 -0
  48. data/lib/igniter/application.rb +174 -0
  49. data/lib/igniter/capabilities.rb +68 -0
  50. data/lib/igniter/compiler/validators/dependencies_validator.rb +50 -2
  51. data/lib/igniter/compiler/validators/remote_validator.rb +2 -0
  52. data/lib/igniter/consensus/cluster.rb +183 -0
  53. data/lib/igniter/consensus/errors.rb +14 -0
  54. data/lib/igniter/consensus/executors.rb +43 -0
  55. data/lib/igniter/consensus/node.rb +320 -0
  56. data/lib/igniter/consensus/read_query.rb +30 -0
  57. data/lib/igniter/consensus/state_machine.rb +58 -0
  58. data/lib/igniter/consensus.rb +58 -0
  59. data/lib/igniter/content_addressing.rb +133 -0
  60. data/lib/igniter/contract.rb +12 -0
  61. data/lib/igniter/dataflow/aggregate_operators.rb +147 -0
  62. data/lib/igniter/dataflow/aggregate_state.rb +77 -0
  63. data/lib/igniter/dataflow/diff.rb +37 -0
  64. data/lib/igniter/dataflow/diff_state.rb +81 -0
  65. data/lib/igniter/dataflow/incremental_collection_result.rb +39 -0
  66. data/lib/igniter/dataflow/window_filter.rb +48 -0
  67. data/lib/igniter/dataflow.rb +65 -0
  68. data/lib/igniter/dsl/contract_builder.rb +71 -7
  69. data/lib/igniter/executor.rb +60 -0
  70. data/lib/igniter/extensions/capabilities.rb +39 -0
  71. data/lib/igniter/extensions/content_addressing.rb +5 -0
  72. data/lib/igniter/extensions/dataflow.rb +117 -0
  73. data/lib/igniter/extensions/incremental.rb +50 -0
  74. data/lib/igniter/extensions/mesh.rb +31 -0
  75. data/lib/igniter/fingerprint.rb +43 -0
  76. data/lib/igniter/incremental/formatter.rb +81 -0
  77. data/lib/igniter/incremental/result.rb +69 -0
  78. data/lib/igniter/incremental/tracker.rb +108 -0
  79. data/lib/igniter/incremental.rb +50 -0
  80. data/lib/igniter/integrations/llm/config.rb +48 -4
  81. data/lib/igniter/integrations/llm/executor.rb +221 -28
  82. data/lib/igniter/integrations/llm/providers/anthropic.rb +37 -4
  83. data/lib/igniter/integrations/llm/providers/openai.rb +34 -5
  84. data/lib/igniter/integrations/llm/transcription/providers/assemblyai.rb +200 -0
  85. data/lib/igniter/integrations/llm/transcription/providers/base.rb +122 -0
  86. data/lib/igniter/integrations/llm/transcription/providers/deepgram.rb +162 -0
  87. data/lib/igniter/integrations/llm/transcription/providers/openai.rb +102 -0
  88. data/lib/igniter/integrations/llm/transcription/transcriber.rb +145 -0
  89. data/lib/igniter/integrations/llm/transcription/transcript_result.rb +29 -0
  90. data/lib/igniter/integrations/llm.rb +37 -1
  91. data/lib/igniter/memory/agent_memory.rb +104 -0
  92. data/lib/igniter/memory/episode.rb +29 -0
  93. data/lib/igniter/memory/fact.rb +27 -0
  94. data/lib/igniter/memory/memorable.rb +90 -0
  95. data/lib/igniter/memory/reflection_cycle.rb +96 -0
  96. data/lib/igniter/memory/reflection_record.rb +28 -0
  97. data/lib/igniter/memory/store.rb +115 -0
  98. data/lib/igniter/memory/stores/in_memory.rb +136 -0
  99. data/lib/igniter/memory/stores/sqlite.rb +284 -0
  100. data/lib/igniter/memory.rb +80 -0
  101. data/lib/igniter/mesh/announcer.rb +55 -0
  102. data/lib/igniter/mesh/config.rb +45 -0
  103. data/lib/igniter/mesh/discovery.rb +39 -0
  104. data/lib/igniter/mesh/errors.rb +31 -0
  105. data/lib/igniter/mesh/gossip.rb +47 -0
  106. data/lib/igniter/mesh/peer.rb +21 -0
  107. data/lib/igniter/mesh/peer_registry.rb +51 -0
  108. data/lib/igniter/mesh/poller.rb +77 -0
  109. data/lib/igniter/mesh/router.rb +109 -0
  110. data/lib/igniter/mesh.rb +85 -0
  111. data/lib/igniter/metrics/collector.rb +131 -0
  112. data/lib/igniter/metrics/prometheus_exporter.rb +104 -0
  113. data/lib/igniter/metrics/snapshot.rb +8 -0
  114. data/lib/igniter/metrics.rb +37 -0
  115. data/lib/igniter/model/aggregate_node.rb +34 -0
  116. data/lib/igniter/model/collection_node.rb +3 -2
  117. data/lib/igniter/model/compute_node.rb +13 -0
  118. data/lib/igniter/model/remote_node.rb +18 -2
  119. data/lib/igniter/node_cache.rb +231 -0
  120. data/lib/igniter/replication/bootstrapper.rb +61 -0
  121. data/lib/igniter/replication/bootstrappers/gem.rb +32 -0
  122. data/lib/igniter/replication/bootstrappers/git.rb +39 -0
  123. data/lib/igniter/replication/bootstrappers/tarball.rb +56 -0
  124. data/lib/igniter/replication/expansion_plan.rb +38 -0
  125. data/lib/igniter/replication/expansion_planner.rb +142 -0
  126. data/lib/igniter/replication/manifest.rb +45 -0
  127. data/lib/igniter/replication/network_topology.rb +123 -0
  128. data/lib/igniter/replication/node_role.rb +42 -0
  129. data/lib/igniter/replication/reflective_replication_agent.rb +238 -0
  130. data/lib/igniter/replication/replication_agent.rb +87 -0
  131. data/lib/igniter/replication/role_registry.rb +73 -0
  132. data/lib/igniter/replication/ssh_session.rb +77 -0
  133. data/lib/igniter/replication.rb +54 -0
  134. data/lib/igniter/runtime/cache.rb +35 -6
  135. data/lib/igniter/runtime/execution.rb +26 -2
  136. data/lib/igniter/runtime/input_validator.rb +6 -2
  137. data/lib/igniter/runtime/node_state.rb +7 -2
  138. data/lib/igniter/runtime/resolver.rb +323 -31
  139. data/lib/igniter/runtime/stores/redis_store.rb +41 -4
  140. data/lib/igniter/server/client.rb +44 -1
  141. data/lib/igniter/server/config.rb +13 -6
  142. data/lib/igniter/server/handlers/event_handler.rb +4 -0
  143. data/lib/igniter/server/handlers/execute_handler.rb +6 -0
  144. data/lib/igniter/server/handlers/liveness_handler.rb +20 -0
  145. data/lib/igniter/server/handlers/manifest_handler.rb +34 -0
  146. data/lib/igniter/server/handlers/metrics_handler.rb +51 -0
  147. data/lib/igniter/server/handlers/peers_handler.rb +115 -0
  148. data/lib/igniter/server/handlers/readiness_handler.rb +47 -0
  149. data/lib/igniter/server/http_server.rb +54 -17
  150. data/lib/igniter/server/router.rb +54 -21
  151. data/lib/igniter/server/server_logger.rb +52 -0
  152. data/lib/igniter/server.rb +6 -0
  153. data/lib/igniter/skill/feedback.rb +116 -0
  154. data/lib/igniter/skill/output_schema.rb +110 -0
  155. data/lib/igniter/skill.rb +218 -0
  156. data/lib/igniter/temporal.rb +84 -0
  157. data/lib/igniter/tool/discoverable.rb +151 -0
  158. data/lib/igniter/tool.rb +52 -0
  159. data/lib/igniter/tool_registry.rb +144 -0
  160. data/lib/igniter/version.rb +1 -1
  161. data/lib/igniter.rb +17 -0
  162. metadata +128 -1
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Mesh
5
+ # Configuration for the local mesh node: registered peers and local identity.
6
+ class Config
7
+ attr_accessor :peer_name, :local_capabilities, :seeds, :discovery_interval,
8
+ :auto_announce, :local_url, :gossip_fanout
9
+ attr_reader :peers, :peer_registry
10
+
11
+ def initialize
12
+ @peer_name = nil
13
+ @local_capabilities = []
14
+ @peers = []
15
+ @peer_registry = PeerRegistry.new
16
+ @seeds = []
17
+ @discovery_interval = 30
18
+ @auto_announce = true
19
+ @local_url = nil
20
+ @gossip_fanout = 3
21
+ end
22
+
23
+ # Register a remote peer by name.
24
+ #
25
+ # Igniter::Mesh.configure do |c|
26
+ # c.add_peer "orders-node", url: "http://orders.internal:4567",
27
+ # capabilities: [:orders, :inventory]
28
+ # end
29
+ def add_peer(name, url:, capabilities: [])
30
+ @peers << Peer.new(name: name, url: url, capabilities: capabilities)
31
+ self
32
+ end
33
+
34
+ # All static peers that advertise a given capability.
35
+ def peers_with_capability(capability)
36
+ @peers.select { |p| p.capable?(capability) }
37
+ end
38
+
39
+ # Find a static peer by its registered name. Returns nil if not found.
40
+ def peer_named(name)
41
+ @peers.find { |p| p.name == name.to_s }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Mesh
5
+ # Orchestrates the full dynamic-discovery lifecycle:
6
+ #
7
+ # 1. Announce self to all seeds (POST /v1/mesh/peers).
8
+ # 2. Immediately poll all seeds for their known peer list (GET /v1/mesh/peers).
9
+ # 3. Start the background Poller to keep the registry up to date.
10
+ #
11
+ # On #stop:
12
+ # 1. Deannounce self from all seeds (DELETE /v1/mesh/peers/:name), best-effort.
13
+ # 2. Stop the background Poller.
14
+ class Discovery
15
+ def initialize(config)
16
+ @config = config
17
+ @announcer = Announcer.new(config)
18
+ @poller = Poller.new(config)
19
+ end
20
+
21
+ def start
22
+ @announcer.announce_all
23
+ @poller.poll_once
24
+ @poller.start
25
+ self
26
+ end
27
+
28
+ def stop
29
+ @announcer.deannounce_all
30
+ @poller.stop
31
+ self
32
+ end
33
+
34
+ def running?
35
+ @poller.running?
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Mesh
5
+ # Raised when a capability-routed remote node has no alive peers.
6
+ # Inherits from PendingDependencyError so the resolver transitions
7
+ # the node to :pending (same as await/distributed workflow nodes).
8
+ class DeferredCapabilityError < Igniter::PendingDependencyError
9
+ attr_reader :capability
10
+
11
+ def initialize(capability, deferred_result, message = nil)
12
+ @capability = capability
13
+ super(deferred_result, message || "No alive peer with capability :#{capability}")
14
+ end
15
+ end
16
+
17
+ # Raised when a pinned_to peer is unavailable.
18
+ # Inherits from ResolutionError so the resolver transitions the node
19
+ # to :failed and surfaces it as an operational incident requiring
20
+ # manual intervention.
21
+ class IncidentError < Igniter::ResolutionError
22
+ attr_reader :peer_name
23
+
24
+ def initialize(peer_name, message = nil, context: {})
25
+ @peer_name = peer_name
26
+ super(message || "Pinned peer '#{peer_name}' is unreachable — manual intervention required",
27
+ context: context)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Mesh
5
+ # Executes a single peer-to-peer gossip round.
6
+ #
7
+ # Picks up to config.gossip_fanout random peers from the local PeerRegistry
8
+ # (excluding self), fetches their GET /v1/mesh/peers lists, and registers
9
+ # any newly discovered peers. This decentralises topology exchange: once seeds
10
+ # bootstrap the registry, peers continue spreading topology information among
11
+ # themselves even if seeds become unavailable.
12
+ #
13
+ # Errors from individual peers are swallowed — a dead peer must not abort
14
+ # the round for the remaining candidates.
15
+ class GossipRound
16
+ def initialize(config)
17
+ @config = config
18
+ end
19
+
20
+ def run
21
+ pick_candidates.each { |peer| exchange_with(peer) }
22
+ end
23
+
24
+ private
25
+
26
+ def pick_candidates
27
+ @config.peer_registry.all
28
+ .reject { |p| p.url == @config.local_url }
29
+ .sample(@config.gossip_fanout)
30
+ end
31
+
32
+ def exchange_with(peer) # rubocop:disable Metrics/AbcSize
33
+ peers = Igniter::Server::Client.new(peer.url, timeout: 5).list_peers
34
+ peers.each do |pd|
35
+ next if pd[:name].nil? || pd[:url].nil?
36
+ next if pd[:name] == @config.peer_name
37
+
38
+ @config.peer_registry.register(
39
+ Peer.new(name: pd[:name], url: pd[:url], capabilities: pd[:capabilities] || [])
40
+ )
41
+ end
42
+ rescue Igniter::Server::Client::ConnectionError
43
+ nil
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Mesh
5
+ # Immutable value object representing a peer in the static mesh.
6
+ class Peer
7
+ attr_reader :name, :url, :capabilities
8
+
9
+ def initialize(name:, url:, capabilities: [])
10
+ @name = name.to_s.freeze
11
+ @url = url.to_s.chomp("/").freeze
12
+ @capabilities = Array(capabilities).map(&:to_sym).freeze
13
+ freeze
14
+ end
15
+
16
+ def capable?(capability)
17
+ @capabilities.include?(capability.to_sym)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Mesh
5
+ # Thread-safe registry for dynamically discovered peers.
6
+ #
7
+ # Stores peers indexed by name so that registering the same peer twice
8
+ # (e.g. from two different seeds) is idempotent and the latest wins.
9
+ class PeerRegistry
10
+ def initialize
11
+ @peers = {}
12
+ @mutex = Mutex.new
13
+ end
14
+
15
+ # Register (or update) a peer. Thread-safe.
16
+ def register(peer)
17
+ @mutex.synchronize { @peers[peer.name] = peer }
18
+ end
19
+
20
+ # Remove a peer by name. No-op if not registered.
21
+ def unregister(name)
22
+ @mutex.synchronize { @peers.delete(name.to_s) }
23
+ end
24
+
25
+ # All currently registered peers (snapshot).
26
+ def all
27
+ @mutex.synchronize { @peers.values.dup }
28
+ end
29
+
30
+ # Peers that advertise a given capability.
31
+ def peers_with_capability(capability)
32
+ all.select { |p| p.capable?(capability) }
33
+ end
34
+
35
+ # Find a peer by name. Returns nil if not found.
36
+ def peer_named(name)
37
+ @mutex.synchronize { @peers[name.to_s] }
38
+ end
39
+
40
+ # Remove all registered peers. Useful in tests.
41
+ def clear
42
+ @mutex.synchronize { @peers.clear }
43
+ end
44
+
45
+ # Number of registered peers.
46
+ def size
47
+ @mutex.synchronize { @peers.size }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Mesh
5
+ # Background thread that periodically fetches the peer list from every seed
6
+ # and populates the local PeerRegistry with newly discovered peers.
7
+ #
8
+ # - Errors from individual seeds are swallowed (seed may be temporarily down).
9
+ # - Does not remove peers from the registry on failure — the health cache in
10
+ # Router handles routing around dead peers without purging known topology.
11
+ # - Thread is non-daemon: call #stop explicitly on shutdown (Discovery does this).
12
+ class Poller
13
+ def initialize(config)
14
+ @config = config
15
+ @running = false
16
+ @thread = nil
17
+ @mutex = Mutex.new
18
+ end
19
+
20
+ # Start the background polling thread. Idempotent.
21
+ def start
22
+ @mutex.synchronize do
23
+ return if @running
24
+
25
+ @running = true
26
+ @thread = Thread.new { run_loop }
27
+ @thread.abort_on_exception = false
28
+ end
29
+ end
30
+
31
+ # Stop the background thread. Idempotent.
32
+ def stop
33
+ @mutex.synchronize do
34
+ @running = false
35
+ @thread&.kill
36
+ @thread = nil
37
+ end
38
+ end
39
+
40
+ def running?
41
+ @mutex.synchronize { @running }
42
+ end
43
+
44
+ # Fetch peers from all seeds, then run a gossip round with random registry
45
+ # peers (Phase 3). Synchronous — used at startup and inside the background loop.
46
+ def poll_once
47
+ @config.seeds.each { |url| fetch_peers_from(url) }
48
+ GossipRound.new(@config).run if @config.gossip_fanout.positive?
49
+ end
50
+
51
+ private
52
+
53
+ def run_loop
54
+ loop do
55
+ sleep(@config.discovery_interval)
56
+ break unless @running
57
+
58
+ poll_once
59
+ end
60
+ end
61
+
62
+ def fetch_peers_from(seed_url)
63
+ peers = Igniter::Server::Client.new(seed_url, timeout: 5).list_peers
64
+ peers.each do |pd|
65
+ next if pd[:name].nil? || pd[:url].nil?
66
+ next if pd[:name] == @config.peer_name
67
+
68
+ @config.peer_registry.register(
69
+ Peer.new(name: pd[:name], url: pd[:url], capabilities: pd[:capabilities] || [])
70
+ )
71
+ end
72
+ rescue Igniter::Server::Client::ConnectionError
73
+ nil
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Mesh
5
+ # Thread-safe capability router with a short-lived health cache.
6
+ #
7
+ # Resolves the URL to call for a given routing mode:
8
+ # - :capability → round-robin among alive peers that advertise the capability;
9
+ # raises DeferredCapabilityError when no alive peer is found.
10
+ # - :pinned → asserts the named peer is alive and returns its URL;
11
+ # raises IncidentError when the peer is unknown or unreachable.
12
+ #
13
+ # Peer pool = static peers (Config#peers) + dynamic peers (Config#peer_registry).
14
+ # Static peers take precedence when a name appears in both sets.
15
+ class Router
16
+ HEALTH_CACHE_TTL = 5 # seconds
17
+
18
+ def initialize(config)
19
+ @config = config
20
+ @health_cache = {}
21
+ @mutex = Mutex.new
22
+ @round_robin = Hash.new(0)
23
+ end
24
+
25
+ # Find an alive peer advertising +capability+.
26
+ # Returns the peer URL. Raises DeferredCapabilityError when none are alive.
27
+ def find_peer_for(capability, deferred_result)
28
+ candidates = all_capable_peers(capability).select { |p| alive?(p) }
29
+
30
+ raise DeferredCapabilityError.new(capability, deferred_result) if candidates.empty?
31
+
32
+ url_for_round_robin(capability, candidates)
33
+ end
34
+
35
+ # Resolve the URL of a pinned peer by name.
36
+ # Raises IncidentError if the peer is unknown or unreachable.
37
+ def resolve_pinned(peer_name)
38
+ peer = find_named_peer(peer_name)
39
+
40
+ unless peer
41
+ raise IncidentError.new(
42
+ peer_name,
43
+ "Pinned peer '#{peer_name}' is not registered in Igniter::Mesh"
44
+ )
45
+ end
46
+
47
+ raise IncidentError, peer_name unless alive?(peer)
48
+
49
+ peer.url
50
+ end
51
+
52
+ # Expire a peer's cached health status (e.g., after a successful or failed request).
53
+ def invalidate_health!(url)
54
+ @mutex.synchronize { @health_cache.delete(url) }
55
+ end
56
+
57
+ private
58
+
59
+ # Combined static + dynamic peer pool for capability lookup.
60
+ # Static peers take precedence over same-named dynamic peers.
61
+ def all_capable_peers(capability)
62
+ merge_peers(
63
+ @config.peers_with_capability(capability),
64
+ @config.peer_registry.peers_with_capability(capability)
65
+ )
66
+ end
67
+
68
+ # Lookup peer by name across static and dynamic pools.
69
+ def find_named_peer(name)
70
+ @config.peer_named(name) || @config.peer_registry.peer_named(name)
71
+ end
72
+
73
+ # Merge static and dynamic peer lists; static names win on collision.
74
+ def merge_peers(static, dynamic)
75
+ seen = static.each_with_object({}) { |p, h| h[p.name] = true }
76
+ static + dynamic.reject { |p| seen[p.name] }
77
+ end
78
+
79
+ def url_for_round_robin(capability, candidates)
80
+ idx = @mutex.synchronize do
81
+ i = @round_robin[capability] % candidates.size
82
+ @round_robin[capability] = i + 1
83
+ i
84
+ end
85
+ candidates[idx].url
86
+ end
87
+
88
+ def alive?(peer) # rubocop:disable Metrics/MethodLength
89
+ @mutex.synchronize do
90
+ entry = @health_cache[peer.url]
91
+ return entry[:alive] if entry && (Time.now.utc - entry[:checked_at]) < HEALTH_CACHE_TTL
92
+ end
93
+
94
+ alive = begin
95
+ Igniter::Server::Client.new(peer.url, timeout: 3).health
96
+ true
97
+ rescue Igniter::Server::Client::ConnectionError
98
+ false
99
+ end
100
+
101
+ @mutex.synchronize do
102
+ @health_cache[peer.url] = { alive: alive, checked_at: Time.now.utc }
103
+ end
104
+
105
+ alive
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mesh/errors"
4
+ require_relative "mesh/peer"
5
+ require_relative "mesh/peer_registry"
6
+ require_relative "mesh/config"
7
+ require_relative "mesh/router"
8
+ require_relative "mesh/announcer"
9
+ require_relative "mesh/poller"
10
+ require_relative "mesh/discovery"
11
+ require_relative "mesh/gossip"
12
+
13
+ module Igniter
14
+ # Mesh routing for remote: nodes.
15
+ #
16
+ # Phase 1 — Static Mesh:
17
+ # Declare peer topology via add_peer. capability: and pinned_to: routing
18
+ # modes select alive peers at resolution time.
19
+ #
20
+ # Phase 2 — Dynamic Discovery:
21
+ # Configure seed URLs and call start_discovery!. The local node announces
22
+ # itself to seeds and polls them for the current peer list in the background.
23
+ #
24
+ # Usage:
25
+ #
26
+ # require "igniter/extensions/mesh"
27
+ #
28
+ # Igniter::Mesh.configure do |c|
29
+ # c.peer_name = "api-node"
30
+ # c.local_url = "http://api.internal:4567"
31
+ # c.local_capabilities = [:api]
32
+ # c.seeds = %w[http://orders.internal:4567 http://audit.internal:4567]
33
+ # c.discovery_interval = 30 # seconds (default)
34
+ #
35
+ # # Static peers still work alongside dynamic discovery:
36
+ # c.add_peer "legacy-node",
37
+ # url: "http://legacy.internal:4567",
38
+ # capabilities: [:billing]
39
+ # end
40
+ #
41
+ # Igniter::Mesh.start_discovery! # announce + poll + background thread
42
+ # # …
43
+ # Igniter::Mesh.stop_discovery! # deannounce + stop thread (on shutdown)
44
+ module Mesh
45
+ class << self
46
+ def config
47
+ @config ||= Config.new
48
+ end
49
+
50
+ def configure
51
+ yield config
52
+ self
53
+ end
54
+
55
+ def router
56
+ @router ||= Router.new(config)
57
+ end
58
+
59
+ # Start dynamic discovery: announce self to seeds, do an immediate poll,
60
+ # then begin background polling at config.discovery_interval.
61
+ def start_discovery!
62
+ discovery.start
63
+ self
64
+ end
65
+
66
+ # Stop dynamic discovery: deannounce self from seeds, stop background thread.
67
+ def stop_discovery!
68
+ @discovery&.stop
69
+ @discovery = nil
70
+ self
71
+ end
72
+
73
+ def discovery
74
+ @discovery ||= Discovery.new(config)
75
+ end
76
+
77
+ # Reset all singletons (config, router, discovery). Useful in tests.
78
+ def reset!
79
+ stop_discovery!
80
+ @config = nil
81
+ @router = nil
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Metrics
5
+ # Thread-safe event subscriber that collects execution-level metrics.
6
+ #
7
+ # Subscribes to an Igniter::Events::Bus (via the Events::Bus#subscribe interface)
8
+ # and maintains in-memory counters and histograms for:
9
+ # - Total executions (by graph, by status)
10
+ # - Execution duration histogram (by graph)
11
+ # - HTTP request counts and durations (recorded directly by the server)
12
+ #
13
+ # All state is protected by a Mutex. Snapshot returns a frozen copy
14
+ # safe to read outside the lock.
15
+ class Collector
16
+ HISTOGRAM_BUCKETS = [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0].freeze
17
+
18
+ def initialize
19
+ @mutex = Mutex.new
20
+ @counters = Hash.new(0) # String(metric{labels}) → Integer
21
+ @histograms = {} # String(metric_name) → Hash(label_key → histogram_entry)
22
+ @exec_start = {} # execution_id → Time (for duration tracking)
23
+ @exec_graph = {} # execution_id → graph_name
24
+ end
25
+
26
+ # Called by Events::Bus for every event emitted during an execution.
27
+ def call(event) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
28
+ case event.type
29
+ when :execution_started then on_execution_started(event)
30
+ when :execution_finished then on_execution_finished(event, "succeeded")
31
+ when :execution_failed then on_execution_finished(event, "failed")
32
+ end
33
+ end
34
+
35
+ # Record an HTTP request (called directly by the server/router).
36
+ def record_http(method:, path:, status:, duration:)
37
+ @mutex.synchronize do
38
+ inc("igniter_http_requests_total",
39
+ method: method, path: normalized_path(path), status: status.to_s)
40
+ observe_locked("igniter_http_request_duration_seconds", duration,
41
+ method: method, path: normalized_path(path))
42
+ end
43
+ end
44
+
45
+ # Snapshot returns frozen copies of counters and histograms.
46
+ def snapshot
47
+ @mutex.synchronize do
48
+ Snapshot.new(
49
+ counters: @counters.dup.freeze,
50
+ histograms: deep_freeze(@histograms)
51
+ )
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def on_execution_started(event)
58
+ @mutex.synchronize do
59
+ @exec_start[event.execution_id] = event.timestamp
60
+ @exec_graph[event.execution_id] = event.payload[:graph].to_s
61
+ end
62
+ end
63
+
64
+ def on_execution_finished(event, status)
65
+ @mutex.synchronize do
66
+ graph = @exec_graph.delete(event.execution_id) || event.payload[:graph].to_s
67
+ started_at = @exec_start.delete(event.execution_id)
68
+
69
+ inc("igniter_executions_total", graph: graph, status: status)
70
+
71
+ if started_at
72
+ duration = event.timestamp - started_at
73
+ observe_locked("igniter_execution_duration_seconds", duration, graph: graph)
74
+ end
75
+ end
76
+ end
77
+
78
+ def inc(name, labels)
79
+ @counters[metric_key(name, labels)] += 1
80
+ end
81
+
82
+ def observe_locked(name, value, labels)
83
+ lkey = label_key(labels)
84
+ @histograms[name] ||= {}
85
+ entry = @histograms[name][lkey] ||= new_histogram_entry(labels)
86
+ HISTOGRAM_BUCKETS.each { |b| entry[:buckets][b] += 1 if value <= b }
87
+ entry[:sum] += value
88
+ entry[:count] += 1
89
+ end
90
+
91
+ def new_histogram_entry(labels)
92
+ { labels: labels, buckets: Hash.new(0), sum: 0.0, count: 0 }
93
+ end
94
+
95
+ def metric_key(name, labels)
96
+ "#{name}#{label_selector(labels)}"
97
+ end
98
+
99
+ def label_key(labels)
100
+ label_selector(labels)
101
+ end
102
+
103
+ def label_selector(labels)
104
+ return "" if labels.empty?
105
+
106
+ pairs = labels.map { |k, v| "#{k}=\"#{v}\"" }.join(",")
107
+ "{#{pairs}}"
108
+ end
109
+
110
+ def normalized_path(path)
111
+ # Collapse dynamic path segments to avoid high-cardinality labels
112
+ path.to_s
113
+ .gsub(%r{/v1/contracts/[^/]+/}, "/v1/contracts/:name/")
114
+ .gsub(%r{/v1/executions/[^/]+}, "/v1/executions/:id")
115
+ end
116
+
117
+ def deep_freeze(hash)
118
+ hash.each_with_object({}) do |(name, by_label), memo|
119
+ memo[name] = by_label.each_with_object({}) do |(lkey, entry), inner|
120
+ inner[lkey] = {
121
+ labels: entry[:labels].dup.freeze,
122
+ buckets: entry[:buckets].dup.freeze,
123
+ sum: entry[:sum],
124
+ count: entry[:count]
125
+ }.freeze
126
+ end.freeze
127
+ end.freeze
128
+ end
129
+ end
130
+ end
131
+ end