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,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Igniter
6
+ class Application
7
+ # Generates a new Igniter application scaffold.
8
+ # Invoked via: igniter-server new my_app
9
+ #
10
+ # Creates:
11
+ # my_app/
12
+ # ├── application.rb — Application class (entry point)
13
+ # ├── application.yml — base config (port, log_format, etc.)
14
+ # ├── Gemfile
15
+ # ├── config.ru — Rack entry point for Puma/Unicorn
16
+ # ├── bin/start — convenience start script
17
+ # ├── contracts/ — Contract subclasses
18
+ # ├── executors/ — Executor subclasses
19
+ # └── agents/ — Agent, Supervisor, and ProactiveAgent subclasses
20
+ class Generator
21
+ def initialize(name)
22
+ @name = name.to_s.strip
23
+ raise ArgumentError, "App name cannot be blank" if @name.empty?
24
+
25
+ @dir = @name
26
+ end
27
+
28
+ def generate # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
29
+ create_dir ""
30
+ create_dir "contracts"
31
+ create_dir "executors"
32
+ create_dir "agents"
33
+ create_dir "bin"
34
+
35
+ write "application.rb", application_rb
36
+ write "application.yml", application_yml
37
+ write "Gemfile", gemfile
38
+ write "config.ru", config_ru
39
+ write "bin/start", bin_start
40
+ write "contracts/.keep", ""
41
+ write "executors/.keep", ""
42
+ write "agents/.keep", ""
43
+
44
+ FileUtils.chmod(0o755, path("bin/start"))
45
+
46
+ puts
47
+ puts "Done! Next steps:"
48
+ puts " cd #{@name}"
49
+ puts " bundle install"
50
+ puts " bin/start"
51
+ puts
52
+ puts "To run with Puma:"
53
+ puts " bundle add puma"
54
+ puts " bundle exec puma config.ru"
55
+ end
56
+
57
+ private
58
+
59
+ def path(rel) = File.join(@dir, rel)
60
+
61
+ def create_dir(rel)
62
+ full = rel.empty? ? @dir : path(rel)
63
+ FileUtils.mkdir_p(full)
64
+ puts " create #{full}/"
65
+ end
66
+
67
+ def write(rel, content)
68
+ full = path(rel)
69
+ File.write(full, content)
70
+ puts " create #{full}"
71
+ end
72
+
73
+ def module_name
74
+ @name.split(/[-_\s]+/).map(&:capitalize).join
75
+ end
76
+
77
+ def application_rb
78
+ <<~RUBY
79
+ # frozen_string_literal: true
80
+
81
+ $LOAD_PATH.unshift(File.join(__dir__, "../../lib")) if File.exist?("../../lib/igniter.rb")
82
+
83
+ require "igniter"
84
+ require "igniter/server"
85
+ require "igniter/application"
86
+ require "igniter/agents"
87
+
88
+ class #{module_name}App < Igniter::Application
89
+ config_file File.join(__dir__, "application.yml")
90
+
91
+ # Eagerly load all .rb files from these directories on startup.
92
+ executors_path "executors"
93
+ contracts_path "contracts"
94
+ agents_path "agents"
95
+
96
+ configure do |c|
97
+ # Override YAML values here, e.g.:
98
+ # c.port = ENV.fetch("PORT", 4567).to_i
99
+ # c.store = Igniter::Runtime::Stores::MemoryStore.new
100
+ end
101
+
102
+ # Register contracts for HTTP dispatch:
103
+ # register "MyContract", MyContract
104
+
105
+ # Recurring background jobs:
106
+ # schedule :heartbeat, every: "30s" do
107
+ # puts "[heartbeat] \#{Time.now.strftime("%H:%M:%S")}"
108
+ # end
109
+ end
110
+
111
+ #{module_name}App.start if $PROGRAM_NAME == __FILE__
112
+ RUBY
113
+ end
114
+
115
+ def application_yml
116
+ <<~YAML
117
+ server:
118
+ port: 4567
119
+ host: "0.0.0.0"
120
+ log_format: text # text | json (json = Loki/ELK compatible)
121
+ drain_timeout: 30 # seconds to wait for in-flight requests on shutdown
122
+ YAML
123
+ end
124
+
125
+ def gemfile
126
+ <<~RUBY
127
+ # frozen_string_literal: true
128
+
129
+ source "https://rubygems.org"
130
+
131
+ gem "igniter"
132
+ RUBY
133
+ end
134
+
135
+ def config_ru
136
+ <<~RUBY
137
+ # frozen_string_literal: true
138
+ # Rack entry point — use with Puma or any Rack-compatible server.
139
+ # bundle exec puma config.ru
140
+
141
+ require_relative "application"
142
+
143
+ run #{module_name}App.rack_app
144
+ RUBY
145
+ end
146
+
147
+ def bin_start
148
+ <<~BASH
149
+ #!/usr/bin/env bash
150
+ set -e
151
+ cd "$(dirname "$0")/.."
152
+ exec bundle exec ruby application.rb "$@"
153
+ BASH
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ class Application
5
+ # Lightweight pure-Ruby scheduler for recurring background jobs.
6
+ # Uses one Thread per job — no external dependencies.
7
+ #
8
+ # Usage:
9
+ # s = Scheduler.new
10
+ # s.add :cleanup, every: "1h" do ... end
11
+ # s.add :report, every: "1d", at: "09:00" do ... end
12
+ # s.start
13
+ # # later:
14
+ # s.stop
15
+ #
16
+ # Interval formats accepted by `every:`:
17
+ # Integer / Float — seconds
18
+ # "30s" — 30 seconds
19
+ # "5m" — 5 minutes
20
+ # "2h" — 2 hours
21
+ # "1d" — 1 day
22
+ # { hours: 1, minutes: 30 }
23
+ class Scheduler
24
+ Job = Data.define(:name, :interval, :at_time, :block)
25
+
26
+ def initialize(logger: nil)
27
+ @jobs = []
28
+ @threads = []
29
+ @logger = logger
30
+ @mu = Mutex.new
31
+ @running = false
32
+ end
33
+
34
+ def add(name, every:, at: nil, &block)
35
+ @jobs << Job.new(
36
+ name: name.to_sym,
37
+ interval: parse_interval(every),
38
+ at_time: at,
39
+ block: block
40
+ )
41
+ end
42
+
43
+ def start
44
+ @mu.synchronize { @running = true }
45
+ @jobs.each { |job| @threads << Thread.new { run_job(job) } }
46
+ self
47
+ end
48
+
49
+ def stop
50
+ @mu.synchronize { @running = false }
51
+ @threads.each(&:kill)
52
+ @threads.clear
53
+ end
54
+
55
+ def job_names
56
+ @jobs.map(&:name)
57
+ end
58
+
59
+ private
60
+
61
+ def run_job(job)
62
+ sleep initial_delay(job)
63
+ loop do
64
+ break unless @mu.synchronize { @running }
65
+
66
+ begin
67
+ job.block.call
68
+ rescue => e # rubocop:disable Style/RescueStandardError
69
+ @logger&.error("Scheduler job failed", name: job.name, error: e.message)
70
+ end
71
+
72
+ sleep job.interval
73
+ end
74
+ end
75
+
76
+ # If `at:` is given (e.g. "09:00"), delay until the next occurrence of that time.
77
+ def initial_delay(job)
78
+ return 0 unless job.at_time
79
+
80
+ h, m = job.at_time.split(":").map(&:to_i)
81
+ now = Time.now
82
+ target = Time.new(now.year, now.month, now.day, h, m, 0)
83
+ target += 86_400 if target <= now
84
+ target - now
85
+ end
86
+
87
+ def parse_interval(val) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize,Metrics/CyclomaticComplexity
88
+ case val
89
+ when Integer, Float then val.to_f
90
+ when String
91
+ case val
92
+ when /\A(\d+)s\z/i then Regexp.last_match(1).to_f
93
+ when /\A(\d+)m\z/i then Regexp.last_match(1).to_f * 60
94
+ when /\A(\d+)h\z/i then Regexp.last_match(1).to_f * 3600
95
+ when /\A(\d+)d\z/i then Regexp.last_match(1).to_f * 86_400
96
+ else raise ArgumentError, "Unknown interval: #{val.inspect} (use 30s / 5m / 2h / 1d)"
97
+ end
98
+ when Hash
99
+ val.fetch(:seconds, 0) +
100
+ val.fetch(:minutes, 0) * 60 +
101
+ val.fetch(:hours, 0) * 3600 +
102
+ val.fetch(:days, 0) * 86_400
103
+ else
104
+ raise ArgumentError, "Interval must be Integer, String, or Hash; got #{val.class}"
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Igniter
6
+ class Application
7
+ # Loads an application.yml and applies values to an AppConfig.
8
+ # YAML is loaded BEFORE the Ruby configure block, so blocks always win.
9
+ #
10
+ # Supported YAML structure:
11
+ # server:
12
+ # port: 4567
13
+ # host: "0.0.0.0"
14
+ # log_format: json # "text" or "json"
15
+ # drain_timeout: 30
16
+ class YmlLoader
17
+ # Maps [section, key] paths in YAML to AppConfig setters.
18
+ MAPPINGS = {
19
+ %w[server port] => ->(cfg, v) { cfg.port = Integer(v) },
20
+ %w[server host] => ->(cfg, v) { cfg.host = v.to_s },
21
+ %w[server log_format] => ->(cfg, v) { cfg.log_format = v.to_sym },
22
+ %w[server drain_timeout] => ->(cfg, v) { cfg.drain_timeout = Integer(v) }
23
+ }.freeze
24
+
25
+ def self.load(path)
26
+ return {} unless File.exist?(path.to_s)
27
+
28
+ YAML.safe_load(File.read(path)) || {}
29
+ end
30
+
31
+ def self.apply(config, yml)
32
+ MAPPINGS.each do |keys, setter|
33
+ value = yml.dig(*keys)
34
+ setter.call(config, value) unless value.nil?
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "igniter/server"
4
+ require_relative "application/app_config"
5
+ require_relative "application/yml_loader"
6
+ require_relative "application/autoloader"
7
+ require_relative "application/scheduler"
8
+ require_relative "application/generator"
9
+
10
+ module Igniter
11
+ # Base class for Igniter applications.
12
+ #
13
+ # Provides a unified DSL for configuration, contract registration,
14
+ # scheduled jobs, and server startup — replacing the raw
15
+ # Igniter::Server.configure boilerplate.
16
+ #
17
+ # == Minimal example
18
+ #
19
+ # require "igniter/application"
20
+ #
21
+ # class MyApp < Igniter::Application
22
+ # config_file "application.yml" # optional YAML base config
23
+ #
24
+ # configure do |c|
25
+ # c.port = 4567
26
+ # c.store = Igniter::Runtime::Stores::MemoryStore.new
27
+ # end
28
+ #
29
+ # register "OrderContract", OrderContract
30
+ #
31
+ # schedule :cleanup, every: "1h" do
32
+ # puts "running cleanup..."
33
+ # end
34
+ # end
35
+ #
36
+ # MyApp.start # blocking built-in HTTP server
37
+ # MyApp.rack_app # Rack-compatible app (Puma / Unicorn)
38
+ #
39
+ # == YAML config (application.yml)
40
+ #
41
+ # server:
42
+ # port: 4567
43
+ # host: "0.0.0.0"
44
+ # log_format: json # text (default) or json
45
+ # drain_timeout: 30
46
+ #
47
+ # Values from the YAML file are applied first; the Ruby `configure` block
48
+ # runs afterwards and always wins.
49
+ class Application
50
+ class << self
51
+ # ─── DSL ─────────────────────────────────────────────────────────────────
52
+
53
+ # Path to an optional YAML configuration file.
54
+ # Loaded before the configure block — configure values override YAML.
55
+ def config_file(path)
56
+ @yml_path = path
57
+ end
58
+
59
+ # Configure the application. Block receives an AppConfig instance.
60
+ # May be called multiple times; blocks are applied in order.
61
+ def configure(&block)
62
+ @configure_blocks << block
63
+ end
64
+
65
+ # Declare a directory whose .rb files are eagerly required at startup
66
+ # (before contracts are registered).
67
+ def executors_path(path)
68
+ @executors_paths << path
69
+ end
70
+
71
+ # Declare a directory whose .rb files are eagerly required at startup.
72
+ def contracts_path(path)
73
+ @contracts_paths << path
74
+ end
75
+
76
+ # Declare a directory whose .rb files are eagerly required at startup
77
+ # (agents, supervisors, and other actor-system components).
78
+ def agents_path(path)
79
+ @agents_paths << path
80
+ end
81
+
82
+ # Register a contract class under a name for HTTP dispatch.
83
+ def register(name, contract_class)
84
+ @registered[name.to_s] = contract_class
85
+ end
86
+
87
+ # Define a recurring background job.
88
+ #
89
+ # schedule :report, every: "1d", at: "09:00" do
90
+ # DailyReportContract.new.resolve_all(...)
91
+ # end
92
+ #
93
+ # Interval formats: Integer (seconds), "30s", "5m", "2h", "1d",
94
+ # or Hash { hours: 1, minutes: 30 }.
95
+ def schedule(name, every:, at: nil, &block)
96
+ @scheduled_jobs << { name: name, every: every, at: at, block: block }
97
+ end
98
+
99
+ # ─── Lifecycle ───────────────────────────────────────────────────────────
100
+
101
+ # Start the built-in HTTP server (blocking).
102
+ # Schedules background jobs and registers an at_exit cleanup.
103
+ def start
104
+ sc = build!
105
+ sched = build_scheduler(sc)
106
+ sched&.start
107
+ at_exit { sched&.stop }
108
+ Igniter::Server::HttpServer.new(sc).start
109
+ end
110
+
111
+ # Return a Rack-compatible application (for Puma / Unicorn / etc.).
112
+ def rack_app
113
+ sc = build!
114
+ build_scheduler(sc)&.start
115
+ Igniter::Server::RackApp.new(sc)
116
+ end
117
+
118
+ # Expose the AppConfig (populated after the first build!).
119
+ def config = @app_config
120
+
121
+ # ─── Inheritance isolation ────────────────────────────────────────────────
122
+
123
+ def inherited(subclass)
124
+ super
125
+ subclass.instance_variable_set(:@yml_path, nil)
126
+ subclass.instance_variable_set(:@configure_blocks, [])
127
+ subclass.instance_variable_set(:@executors_paths, [])
128
+ subclass.instance_variable_set(:@contracts_paths, [])
129
+ subclass.instance_variable_set(:@agents_paths, [])
130
+ subclass.instance_variable_set(:@registered, {})
131
+ subclass.instance_variable_set(:@scheduled_jobs, [])
132
+ subclass.instance_variable_set(:@app_config, AppConfig.new)
133
+ subclass.instance_variable_set(:@build_scheduler, nil)
134
+ end
135
+
136
+ private
137
+
138
+ # Build and return a ready Server::Config.
139
+ def build!
140
+ cfg = @app_config
141
+ apply_yml!(cfg)
142
+ autoload_paths!
143
+ @configure_blocks.each { |b| b.call(cfg) }
144
+ sc = cfg.to_server_config
145
+ @registered.each { |name, klass| sc.register(name, klass) }
146
+ sc
147
+ end
148
+
149
+ def apply_yml!(cfg)
150
+ return unless @yml_path
151
+
152
+ yml = YmlLoader.load(@yml_path)
153
+ YmlLoader.apply(cfg, yml)
154
+ end
155
+
156
+ def autoload_paths!
157
+ loader = Autoloader.new(base_dir: Dir.pwd)
158
+ @executors_paths.each { |p| loader.load_path(p) }
159
+ @contracts_paths.each { |p| loader.load_path(p) }
160
+ @agents_paths.each { |p| loader.load_path(p) }
161
+ end
162
+
163
+ def build_scheduler(server_config)
164
+ return nil if @scheduled_jobs.empty?
165
+
166
+ @build_scheduler ||= begin
167
+ sched = Scheduler.new(logger: server_config.logger)
168
+ @scheduled_jobs.each { |j| sched.add(j[:name], every: j[:every], at: j[:at], &j[:block]) }
169
+ sched
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Igniter
4
+ # Capability-based security for Igniter executors.
5
+ #
6
+ # Executors declare what they are allowed to do via the `capabilities` DSL.
7
+ # A Policy can deny or audit specific capabilities at execution time.
8
+ #
9
+ # == Usage
10
+ #
11
+ # require "igniter/extensions/capabilities"
12
+ #
13
+ # class PaymentExecutor < Igniter::Executor
14
+ # capabilities :network, :external_api
15
+ # end
16
+ #
17
+ # class TaxCalculator < Igniter::Executor
18
+ # pure # shorthand for capabilities :pure
19
+ # end
20
+ #
21
+ # # Inspect a contract's required capabilities:
22
+ # MyContract.compiled_graph.required_capabilities
23
+ # # => { payment: [:network, :external_api], tax: [:pure] }
24
+ #
25
+ # # Enforce a policy:
26
+ # Igniter::Capabilities.policy = Igniter::Capabilities::Policy.new(
27
+ # denied: %i[network external_api]
28
+ # )
29
+ module Capabilities
30
+ KNOWN = %i[pure network database filesystem external_api messaging queue cache].freeze
31
+
32
+ class CapabilityViolationError < Igniter::Error
33
+ def initialize(message, node_name: nil)
34
+ super(message, context: { node_name: node_name }.compact)
35
+ end
36
+ end
37
+
38
+ # Enforce capability rules at execution time.
39
+ #
40
+ # Options:
41
+ # denied: Array of capabilities that must NOT be used.
42
+ # on_unknown: :warn | :ignore (default :ignore) — what to do with undeclared capabilities.
43
+ class Policy
44
+ def initialize(denied: [], on_unknown: :ignore)
45
+ @denied = Array(denied).map(&:to_sym).freeze
46
+ @on_unknown = on_unknown
47
+ end
48
+
49
+ # Raise CapabilityViolationError if the executor uses a denied capability.
50
+ def check!(node_name, executor_class)
51
+ caps = executor_class.declared_capabilities
52
+ violations = caps & @denied
53
+ return if violations.empty?
54
+
55
+ raise CapabilityViolationError.new(
56
+ "Node '#{node_name}' executor #{executor_class.name} " \
57
+ "uses denied capabilities: #{violations.join(", ")}",
58
+ node_name: node_name
59
+ )
60
+ end
61
+ end
62
+
63
+ class << self
64
+ # Global capability policy. nil = no enforcement (default).
65
+ attr_accessor :policy
66
+ end
67
+ end
68
+ end
@@ -19,6 +19,7 @@ module Igniter
19
19
  validate_composition_node!(node) if node.kind == :composition
20
20
  validate_branch_node!(node) if node.kind == :branch
21
21
  validate_collection_node!(node) if node.kind == :collection
22
+ validate_aggregate_node!(node) if node.kind == :aggregate
22
23
 
23
24
  node.dependencies.each do |dependency_name|
24
25
  next if @context.dependency_resolvable?(dependency_name)
@@ -175,10 +176,12 @@ module Igniter
175
176
  raise @context.validation_error(node, "Collection '#{node.name}' references an uncompiled contract")
176
177
  end
177
178
 
178
- unless %i[collect fail_fast].include?(node.mode)
179
- raise @context.validation_error(node, "Collection '#{node.name}' mode must be `:collect` or `:fail_fast`")
179
+ unless %i[collect fail_fast incremental].include?(node.mode)
180
+ raise @context.validation_error(node, "Collection '#{node.name}' mode must be `:collect`, `:fail_fast`, or `:incremental`")
180
181
  end
181
182
 
183
+ validate_window_option!(node) if node.window
184
+
182
185
  child_input_names = node.contract_class.compiled_graph.nodes.select { |child_node| child_node.kind == :input }.map(&:name)
183
186
  return if child_input_names.include?(node.key_name)
184
187
 
@@ -187,6 +190,51 @@ module Igniter
187
190
  "Collection '#{node.name}' key '#{node.key_name}' must be a child contract input"
188
191
  )
189
192
  end
193
+
194
+ def validate_window_option!(node) # rubocop:disable Metrics/MethodLength
195
+ window = node.window
196
+ unless window.is_a?(Hash)
197
+ raise @context.validation_error(node, "Collection '#{node.name}' window: must be a Hash")
198
+ end
199
+
200
+ if window.key?(:last)
201
+ unless window[:last].is_a?(Integer) && window[:last].positive?
202
+ raise @context.validation_error(node, "Collection '#{node.name}' window: { last: } must be a positive Integer")
203
+ end
204
+ elsif window.key?(:seconds)
205
+ unless window.key?(:field)
206
+ raise @context.validation_error(node, "Collection '#{node.name}' window: { seconds: } requires a :field key")
207
+ end
208
+ else
209
+ raise @context.validation_error(node, "Collection '#{node.name}' window: must use :last or :seconds")
210
+ end
211
+ end
212
+
213
+ def validate_aggregate_node!(node)
214
+ source_sym = node.source_collection
215
+ source = @context.runtime_nodes.find { |n| n.name == source_sym }
216
+
217
+ unless source
218
+ raise @context.validation_error(
219
+ node,
220
+ "Aggregate '#{node.name}' references unknown collection '#{source_sym}'"
221
+ )
222
+ end
223
+
224
+ unless source.kind == :collection
225
+ raise @context.validation_error(
226
+ node,
227
+ "Aggregate '#{node.name}': source '#{source_sym}' must be a collection node, got :#{source.kind}"
228
+ )
229
+ end
230
+
231
+ return if source.mode == :incremental
232
+
233
+ raise @context.validation_error(
234
+ node,
235
+ "Aggregate '#{node.name}': source '#{source_sym}' must use mode: :incremental"
236
+ )
237
+ end
190
238
  end
191
239
  end
192
240
  end
@@ -25,6 +25,8 @@ module Igniter
25
25
  private
26
26
 
27
27
  def validate_url!(node)
28
+ return if node.routing_mode != :static
29
+
28
30
  return if node.node_url.start_with?("http://", "https://")
29
31
 
30
32
  raise @context.validation_error(