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
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Replication
5
+ # Thread-safe in-memory registry of known nodes in the deployment network.
6
+ #
7
+ # Updated by ReflectiveReplicationAgent as nodes are spawned, heartbeat-ed,
8
+ # or removed. Can be shared across agent handler invocations via state.
9
+ #
10
+ # @example
11
+ # topology = NetworkTopology.new
12
+ # topology.register(node_id: "abc", host: "10.0.0.2", role: :worker)
13
+ # topology.nodes(role: :worker) # => [NodeEntry]
14
+ # topology.needs_role?(:coordinator) # => true
15
+ class NetworkTopology
16
+ # Mutable record for a single live node (mutated only inside the Mutex).
17
+ NodeEntry = Struct.new(:node_id, :host, :role,
18
+ :registered_at, :last_seen_at, :healthy,
19
+ keyword_init: true)
20
+
21
+ def initialize
22
+ @nodes = {}
23
+ @mutex = Mutex.new
24
+ end
25
+
26
+ # Register or overwrite a node entry.
27
+ #
28
+ # @param node_id [String]
29
+ # @param host [String]
30
+ # @param role [Symbol, nil]
31
+ # @return [NodeEntry]
32
+ def register(node_id:, host:, role: nil)
33
+ now = Time.now
34
+ entry = NodeEntry.new(
35
+ node_id: node_id,
36
+ host: host,
37
+ role: role&.to_sym,
38
+ registered_at: now,
39
+ last_seen_at: now,
40
+ healthy: true
41
+ )
42
+ @mutex.synchronize { @nodes[node_id] = entry }
43
+ entry
44
+ end
45
+
46
+ # Update last_seen_at for a known node (heartbeat).
47
+ #
48
+ # @param node_id [String]
49
+ # @return [Boolean] true if the node was found
50
+ def touch(node_id:)
51
+ @mutex.synchronize do
52
+ entry = @nodes[node_id]
53
+ return false unless entry
54
+
55
+ entry.last_seen_at = Time.now
56
+ true
57
+ end
58
+ end
59
+
60
+ # Mark a node as unhealthy (e.g. SSH unreachable).
61
+ #
62
+ # @param node_id [String]
63
+ # @return [Boolean] true if the node was found
64
+ def mark_unhealthy(node_id:)
65
+ @mutex.synchronize do
66
+ entry = @nodes[node_id]
67
+ return false unless entry
68
+
69
+ entry.healthy = false
70
+ true
71
+ end
72
+ end
73
+
74
+ # Remove a node from the topology.
75
+ #
76
+ # @param node_id [String]
77
+ # @return [NodeEntry, nil] the removed entry, or nil if not found
78
+ def remove(node_id:)
79
+ @mutex.synchronize { @nodes.delete(node_id) }
80
+ end
81
+
82
+ # Return nodes, optionally filtered by role.
83
+ #
84
+ # @param role [Symbol, nil]
85
+ # @return [Array<NodeEntry>]
86
+ def nodes(role: nil)
87
+ @mutex.synchronize do
88
+ entries = @nodes.values.dup
89
+ role ? entries.select { |e| e.role == role.to_sym } : entries
90
+ end
91
+ end
92
+
93
+ # True when no healthy node with the given role exists.
94
+ #
95
+ # @param role [Symbol, String]
96
+ # @return [Boolean]
97
+ def needs_role?(role)
98
+ nodes(role: role).none?(&:healthy)
99
+ end
100
+
101
+ # Count of healthy nodes across all roles.
102
+ #
103
+ # @return [Integer]
104
+ def healthy_count
105
+ @mutex.synchronize { @nodes.values.count(&:healthy) }
106
+ end
107
+
108
+ # Total number of registered nodes.
109
+ #
110
+ # @return [Integer]
111
+ def size
112
+ @mutex.synchronize { @nodes.size }
113
+ end
114
+
115
+ # All registered node IDs.
116
+ #
117
+ # @return [Array<String>]
118
+ def node_ids
119
+ @mutex.synchronize { @nodes.keys.dup }
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Replication
5
+ # Immutable description of a specialised role a differentiated node can assume.
6
+ #
7
+ # When a node replicates with differentiation it carries a NodeRole that
8
+ # shapes its remote configuration: which contracts to activate, which env
9
+ # vars to inject, and which capability tags to advertise in the mesh.
10
+ #
11
+ # @example
12
+ # role = NodeRole.new(
13
+ # name: :worker,
14
+ # contracts: ["ComputeContract"],
15
+ # capabilities: [:compute],
16
+ # env_overrides: { "WORKER_POOL" => "8" },
17
+ # tags: [:cpu_heavy]
18
+ # )
19
+ class NodeRole
20
+ attr_reader :name, :contracts, :capabilities, :env_overrides, :tags
21
+
22
+ def initialize(name:, contracts: [], capabilities: [], env_overrides: {}, tags: [])
23
+ @name = name.to_sym
24
+ @contracts = Array(contracts).map(&:to_s).freeze
25
+ @capabilities = Array(capabilities).map(&:to_sym).freeze
26
+ @env_overrides = Hash(env_overrides).transform_keys(&:to_s).freeze
27
+ @tags = Array(tags).map(&:to_sym).freeze
28
+ freeze
29
+ end
30
+
31
+ def to_h
32
+ {
33
+ name: @name,
34
+ contracts: @contracts,
35
+ capabilities: @capabilities,
36
+ env_overrides: @env_overrides,
37
+ tags: @tags
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,238 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require_relative "replication_agent"
5
+ require_relative "network_topology"
6
+ require_relative "expansion_plan"
7
+ require_relative "expansion_planner"
8
+ require_relative "role_registry"
9
+
10
+ module Igniter
11
+ module Replication
12
+ # ReplicationAgent extended with episodic memory, self-reflection, and
13
+ # topology-aware network expansion.
14
+ #
15
+ # == Additional message types
16
+ #
17
+ # :assess_network — run ExpansionPlanner; execute replicate_role/retire_node actions
18
+ # :reflect — run a ReflectionCycle over recent episodes; store summary in state
19
+ # :register_node — register a remote node in the local NetworkTopology
20
+ # :node_heartbeat — update last_seen_at for a known node
21
+ # :signal_scale — emit a :scale_signal episode (e.g. from load monitors)
22
+ #
23
+ # == State keys (in addition to inherited :events)
24
+ #
25
+ # :topology — NetworkTopology instance (created lazily on first access)
26
+ # :host_pool — Array<String> of candidate hosts
27
+ # :required_roles — Array<Symbol> of roles that must always be present
28
+ # :last_plan — Hash from the most recent ExpansionPlan
29
+ # :last_reflection — String summary from the most recent reflection cycle
30
+ #
31
+ # == Memory
32
+ #
33
+ # Call +enable_class_memory+ in the class body to activate episodic memory.
34
+ # Memory is class-level (shared across handler invocations on this class).
35
+ #
36
+ # == Auto-assessment
37
+ #
38
+ # Call +auto_assess(every: N)+ to schedule periodic topology assessment.
39
+ #
40
+ # @example
41
+ # RoleRegistry.define(:worker, env_overrides: { "POOL" => "4" })
42
+ #
43
+ # class MyAgent < ReflectiveReplicationAgent
44
+ # enable_class_memory
45
+ # auto_assess every: 60
46
+ # end
47
+ #
48
+ # ref = MyAgent.start(initial_state: {
49
+ # topology: NetworkTopology.new,
50
+ # required_roles: [:worker],
51
+ # host_pool: ["10.0.0.2", "10.0.0.3"]
52
+ # })
53
+ # ref.call(:assess_network)
54
+ class ReflectiveReplicationAgent < ReplicationAgent
55
+ initial_state topology: nil, host_pool: [], required_roles: [],
56
+ last_plan: nil, last_reflection: nil
57
+
58
+ # ── Class-level memory ─────────────────────────────────────────────────────
59
+
60
+ class << self
61
+ # Activate episodic memory for this class.
62
+ #
63
+ # @param store [Memory::Store, nil] backing store; defaults to global default
64
+ # @return [void]
65
+ def enable_class_memory(store: nil)
66
+ require "igniter/memory"
67
+ @class_memory_store = store || Igniter::Memory.default_store
68
+ @class_memory_enabled = true
69
+ end
70
+
71
+ # Returns true when class-level memory has been activated.
72
+ #
73
+ # @return [Boolean]
74
+ def class_memory_enabled?
75
+ @class_memory_enabled || false
76
+ end
77
+
78
+ # Returns the AgentMemory facade bound to this class, or nil when disabled.
79
+ #
80
+ # @return [Memory::AgentMemory, nil]
81
+ def class_memory
82
+ return nil unless class_memory_enabled?
83
+
84
+ @class_memory ||= Igniter::Memory::AgentMemory.new(
85
+ store: @class_memory_store,
86
+ agent_id: name.to_s
87
+ )
88
+ end
89
+
90
+ # Reset class-level memory state. Intended for use in tests.
91
+ #
92
+ # @return [void]
93
+ def reset_class_memory!
94
+ @class_memory = nil
95
+ @class_memory_store = nil
96
+ @class_memory_enabled = false
97
+ end
98
+
99
+ # Register a recurring topology assessment.
100
+ #
101
+ # @param every [Numeric] interval in seconds
102
+ # @return [void]
103
+ def auto_assess(every:)
104
+ schedule(:auto_assessment, every: every) do |state:|
105
+ agent = new
106
+ agent.send(:run_assess_network, state, {})
107
+ end
108
+ end
109
+ end
110
+
111
+ # ── deliver: intercept lifecycle events into memory ────────────────────────
112
+
113
+ # Override the no-op deliver from ReplicationAgent to record events.
114
+ # Subclasses can call +super+ and then add their own routing.
115
+ #
116
+ # @param type [Symbol]
117
+ # @param payload [Hash]
118
+ def deliver(type, payload = {})
119
+ self.class.class_memory&.record(
120
+ type: :replication_event,
121
+ content: "#{type}: #{payload.inspect}",
122
+ outcome: type == :replication_failed ? "failure" : "success",
123
+ importance: 0.6
124
+ )
125
+ end
126
+
127
+ # ── Handlers ───────────────────────────────────────────────────────────────
128
+
129
+ # Re-define :replicate (parent's handler is cleared by Agent.inherited).
130
+ on :replicate do |state:, payload:, **|
131
+ agent = new
132
+ agent.send(:run_replicate, payload)
133
+ state
134
+ end
135
+
136
+ on :assess_network do |state:, payload:, **|
137
+ agent = new
138
+ agent.send(:run_assess_network, state, payload)
139
+ end
140
+
141
+ on :reflect do |state:, payload:, **|
142
+ next state unless class_memory_enabled?
143
+
144
+ rec = class_memory.reflect
145
+ class_memory.record(
146
+ type: :reflection,
147
+ content: rec.summary,
148
+ outcome: "success",
149
+ importance: 0.8
150
+ )
151
+ state.merge(last_reflection: rec.summary)
152
+ end
153
+
154
+ on :register_node do |state:, payload:, **|
155
+ topology = state[:topology] || NetworkTopology.new
156
+ topology.register(
157
+ node_id: payload.fetch(:node_id),
158
+ host: payload.fetch(:host),
159
+ role: payload[:role]
160
+ )
161
+ state.merge(topology: topology)
162
+ end
163
+
164
+ on :node_heartbeat do |state:, payload:, **|
165
+ state[:topology]&.touch(node_id: payload.fetch(:node_id))
166
+ state
167
+ end
168
+
169
+ on :signal_scale do |state:, payload:, **|
170
+ role = payload.fetch(:role)
171
+ class_memory&.record(
172
+ type: :scale_signal,
173
+ content: "scale_out:#{role}",
174
+ outcome: nil
175
+ )
176
+ state
177
+ end
178
+
179
+ private
180
+
181
+ # Assess the network topology and execute the resulting plan.
182
+ # Returns the updated state hash.
183
+ #
184
+ # @param state [Hash]
185
+ # @param payload [Hash]
186
+ # @return [Hash]
187
+ def run_assess_network(state, payload)
188
+ topology = state[:topology] || NetworkTopology.new
189
+ planner = ExpansionPlanner.new(
190
+ topology: topology,
191
+ memory: self.class.class_memory,
192
+ required_roles: Array(payload[:required_roles] || state[:required_roles]),
193
+ host_pool: Array(payload[:host_pool] || state[:host_pool])
194
+ )
195
+
196
+ plan = planner.plan
197
+
198
+ plan.actions.each do |action|
199
+ case action[:action]
200
+ when :replicate_role
201
+ run_replicate_role(action, topology)
202
+ when :retire_node
203
+ topology.remove(node_id: action[:node_id])
204
+ deliver(:node_retired, node_id: action[:node_id], host: action[:host])
205
+ end
206
+ end
207
+
208
+ self.class.class_memory&.record(
209
+ type: :assessment,
210
+ content: plan.rationale.to_s,
211
+ outcome: "success"
212
+ )
213
+
214
+ state.merge(topology: topology, last_plan: plan.to_h)
215
+ end
216
+
217
+ # Execute a :replicate_role action: call run_replicate + register in topology.
218
+ #
219
+ # @param action [Hash]
220
+ # @param topology [NetworkTopology]
221
+ def run_replicate_role(action, topology)
222
+ role_obj = RoleRegistry.registered?(action[:role]) ? RoleRegistry.fetch(action[:role]) : nil
223
+ env = role_obj&.env_overrides || {}
224
+
225
+ run_replicate(
226
+ host: action.fetch(:host),
227
+ user: action.fetch(:user, "deploy"),
228
+ strategy: action.fetch(:strategy, :git),
229
+ env: env,
230
+ bootstrapper_options: action.fetch(:bootstrapper_options, {})
231
+ )
232
+
233
+ topology.register(node_id: SecureRandom.uuid, host: action[:host], role: action[:role])
234
+ deliver(:role_replicated, host: action[:host], role: action[:role])
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../agent"
4
+
5
+ module Igniter
6
+ module Replication
7
+ # Agent that handles :replicate messages to deploy Igniter to remote servers.
8
+ #
9
+ # In production, start via ReplicationAgent.start and send messages through
10
+ # the Ref. In tests, instantiate directly and call handle_message/1.
11
+ #
12
+ # Message payload keys:
13
+ # host: [String] (required) remote hostname or IP
14
+ # user: [String] (required) SSH username
15
+ # key: [String] path to SSH private key (optional)
16
+ # port: [Integer] SSH port (default: 22)
17
+ # env: [Hash] environment variables for remote (default: {})
18
+ # strategy: [Symbol] :git, :gem, or :tarball (default: :git)
19
+ # target_path: [String] installation path on remote (default: /opt/igniter)
20
+ # bootstrapper_options: [Hash] forwarded to the bootstrapper constructor
21
+ #
22
+ class ReplicationAgent < Igniter::Agent
23
+ MAX_REPLICAS = 10
24
+
25
+ initial_state events: []
26
+
27
+ # Class-level handler for the agent mailbox runtime.
28
+ # Instantiates a temporary agent to run the replication so that
29
+ # deliver/1 can be overridden in subclasses.
30
+ on :replicate do |state:, payload:, **|
31
+ agent = new
32
+ agent.send(:run_replicate, payload)
33
+ state
34
+ end
35
+
36
+ # Emit a named lifecycle event. Override or stub in tests.
37
+ #
38
+ # @param type [Symbol] event name (e.g. :replication_started)
39
+ # @param payload [Hash] associated data
40
+ def deliver(type, payload = {})
41
+ # Base implementation: no-op. Override in subclasses for real routing.
42
+ end
43
+
44
+ # Process a raw message hash synchronously (used in tests and internal tooling).
45
+ #
46
+ # @param message [Hash] must have :type key; optional :payload key
47
+ def handle_message(message)
48
+ type = message.fetch(:type).to_sym
49
+ payload = message.fetch(:payload, {})
50
+ return unless type == :replicate
51
+
52
+ run_replicate(payload)
53
+ end
54
+
55
+ private
56
+
57
+ def run_replicate(payload) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
58
+ host = payload[:host] || raise(ArgumentError, "host is required")
59
+ user = payload[:user] || raise(ArgumentError, "user is required")
60
+ key = payload[:key]
61
+ port = payload.fetch(:port, 22)
62
+ env = payload.fetch(:env, {})
63
+ strategy = payload.fetch(:strategy, :git).to_sym
64
+ target_path = payload.fetch(:target_path, "/opt/igniter")
65
+ bs_options = payload.fetch(:bootstrapper_options, {})
66
+
67
+ session = SSHSession.new(host: host, user: user, key: key, port: port)
68
+ bootstrapper = Replication.bootstrapper_for(strategy, **bs_options)
69
+ manifest = Manifest.current
70
+
71
+ deliver(:replication_started, host: host, instance_id: manifest.instance_id)
72
+
73
+ bootstrapper.install(session: session, manifest: manifest,
74
+ env: env, target_path: target_path)
75
+ bootstrapper.start(session: session, manifest: manifest, target_path: target_path)
76
+ verified = bootstrapper.verify(session: session, target_path: target_path)
77
+
78
+ deliver(:replication_completed,
79
+ host: host, instance_id: manifest.instance_id, verified: verified)
80
+ rescue SSHSession::SSHError => e
81
+ deliver(:replication_failed, host: host, error: e.message)
82
+ rescue ArgumentError => e
83
+ deliver(:replication_failed, host: payload[:host], error: e.message)
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ module Replication
5
+ # Module-level registry of named NodeRoles.
6
+ #
7
+ # @example
8
+ # RoleRegistry.define(:worker,
9
+ # contracts: ["ComputeContract"],
10
+ # capabilities: [:compute],
11
+ # env_overrides: { "WORKER_POOL" => "4" }
12
+ # )
13
+ #
14
+ # role = RoleRegistry.fetch(:worker)
15
+ # role.env_overrides # => { "WORKER_POOL" => "4" }
16
+ module RoleRegistry
17
+ @roles = {}
18
+
19
+ class << self
20
+ # Define and register a new role.
21
+ #
22
+ # @param name [Symbol, String]
23
+ # @param contracts [Array<String>]
24
+ # @param capabilities [Array<Symbol>]
25
+ # @param env_overrides [Hash]
26
+ # @param tags [Array<Symbol>]
27
+ # @return [NodeRole]
28
+ def define(name, contracts: [], capabilities: [], env_overrides: {}, tags: [])
29
+ role = NodeRole.new(
30
+ name: name,
31
+ contracts: contracts,
32
+ capabilities: capabilities,
33
+ env_overrides: env_overrides,
34
+ tags: tags
35
+ )
36
+ @roles[role.name] = role
37
+ end
38
+
39
+ # Fetch a role by name.
40
+ #
41
+ # @param name [Symbol, String]
42
+ # @return [NodeRole]
43
+ # @raise [ArgumentError] if not registered
44
+ def fetch(name)
45
+ @roles.fetch(name.to_sym) do
46
+ raise ArgumentError,
47
+ "Unknown role: #{name}. Available: #{@roles.keys.join(", ")}"
48
+ end
49
+ end
50
+
51
+ # Returns true if a role with the given name is registered.
52
+ #
53
+ # @param name [Symbol, String]
54
+ # @return [Boolean]
55
+ def registered?(name)
56
+ @roles.key?(name.to_sym)
57
+ end
58
+
59
+ # All registered roles (copy to prevent external mutation).
60
+ #
61
+ # @return [Hash{Symbol => NodeRole}]
62
+ def all
63
+ @roles.dup
64
+ end
65
+
66
+ # Remove all registrations. Useful in tests.
67
+ def reset!
68
+ @roles = {}
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Igniter
6
+ module Replication
7
+ # Thin subprocess wrapper over the +ssh+ and +scp+ CLI tools.
8
+ #
9
+ # Provides exec/exec! for running remote commands and upload! for
10
+ # copying local files to the remote host. No external gems required.
11
+ class SSHSession
12
+ class SSHError < Igniter::Error; end
13
+
14
+ DEFAULT_CONNECT_TIMEOUT = 10
15
+
16
+ def initialize(host:, user:, key: nil, port: 22, connect_timeout: DEFAULT_CONNECT_TIMEOUT)
17
+ @host = host
18
+ @user = user
19
+ @key = key
20
+ @port = port
21
+ @connect_timeout = connect_timeout
22
+ end
23
+
24
+ # Run a command on the remote host. Raises SSHError on non-zero exit.
25
+ # Returns stdout string on success.
26
+ def exec!(command)
27
+ result = exec(command)
28
+ raise SSHError, "SSH command failed on #{@host}: #{command.inspect}\n#{result[:stderr]}" \
29
+ unless result[:success]
30
+
31
+ result[:stdout]
32
+ end
33
+
34
+ # Run a command on the remote host.
35
+ # Returns a Hash: { stdout:, stderr:, success:, exit_code: }
36
+ def exec(command)
37
+ stdout, stderr, status = Open3.capture3(*build_cmd(command))
38
+ { stdout: stdout, stderr: stderr, success: status.success?, exit_code: status.exitstatus }
39
+ end
40
+
41
+ # Upload a local file to the remote host via scp.
42
+ # Raises SSHError on failure.
43
+ def upload!(local_path, remote_path)
44
+ args = ["scp", *scp_opts, "-P", @port.to_s, local_path, "#{@user}@#{@host}:#{remote_path}"]
45
+ _, stderr, status = Open3.capture3(*args)
46
+ raise SSHError, "SCP upload failed to #{@host}: #{stderr}" unless status.success?
47
+ end
48
+
49
+ # Quick connectivity test. Returns true if the remote responds.
50
+ def test_connection
51
+ exec("echo ok")[:success]
52
+ end
53
+
54
+ private
55
+
56
+ def build_cmd(command)
57
+ ["ssh", *ssh_opts, "-p", @port.to_s, "#{@user}@#{@host}", command]
58
+ end
59
+
60
+ def ssh_opts
61
+ opts = [
62
+ "-o", "StrictHostKeyChecking=no",
63
+ "-o", "BatchMode=yes",
64
+ "-o", "ConnectTimeout=#{@connect_timeout}"
65
+ ]
66
+ opts += ["-i", @key] if @key
67
+ opts
68
+ end
69
+
70
+ def scp_opts
71
+ opts = ["-o", "StrictHostKeyChecking=no", "-B"]
72
+ opts += ["-i", @key] if @key
73
+ opts
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "replication/manifest"
4
+ require_relative "replication/ssh_session"
5
+ require_relative "replication/bootstrapper"
6
+ require_relative "replication/bootstrappers/git"
7
+ require_relative "replication/bootstrappers/gem"
8
+ require_relative "replication/bootstrappers/tarball"
9
+ require_relative "replication/replication_agent"
10
+ require_relative "replication/node_role"
11
+ require_relative "replication/role_registry"
12
+ require_relative "replication/network_topology"
13
+ require_relative "replication/expansion_plan"
14
+ require_relative "replication/expansion_planner"
15
+ require_relative "replication/reflective_replication_agent"
16
+
17
+ module Igniter
18
+ # Self-replication capability: deploy a running Igniter instance to a
19
+ # remote server via SSH using one of three deployment strategies.
20
+ #
21
+ # Usage:
22
+ # require "igniter/replication"
23
+ # ref = Igniter::Replication::ReplicationAgent.start
24
+ # ref.send(:replicate,
25
+ # host: "10.0.0.2",
26
+ # user: "deploy",
27
+ # strategy: :git,
28
+ # bootstrapper_options: { repo_url: "https://github.com/org/app" }
29
+ # )
30
+ #
31
+ module Replication
32
+ ReplicationError = Class.new(Igniter::Error)
33
+
34
+ BOOTSTRAPPERS = {
35
+ git: Bootstrappers::Git,
36
+ gem: Bootstrappers::Gem,
37
+ tarball: Bootstrappers::Tarball
38
+ }.freeze
39
+
40
+ # Instantiate the bootstrapper for the given strategy.
41
+ #
42
+ # @param strategy [Symbol] one of :git, :gem, :tarball
43
+ # @param options [Hash] forwarded to the bootstrapper constructor
44
+ # @return [Bootstrapper]
45
+ # @raise [ArgumentError] for unknown strategies
46
+ def self.bootstrapper_for(strategy, **options)
47
+ klass = BOOTSTRAPPERS.fetch(strategy.to_sym) do
48
+ raise ArgumentError,
49
+ "Unknown bootstrapper: #{strategy}. Available: #{BOOTSTRAPPERS.keys.join(", ")}"
50
+ end
51
+ klass.new(**options)
52
+ end
53
+ end
54
+ end