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.
- 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/incremental.rb +142 -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/incremental.rb +50 -0
- data/lib/igniter/extensions/mesh.rb +31 -0
- data/lib/igniter/fingerprint.rb +43 -0
- data/lib/igniter/incremental/formatter.rb +81 -0
- data/lib/igniter/incremental/result.rb +69 -0
- data/lib/igniter/incremental/tracker.rb +108 -0
- data/lib/igniter/incremental.rb +50 -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/cache.rb +35 -6
- data/lib/igniter/runtime/execution.rb +26 -2
- data/lib/igniter/runtime/input_validator.rb +6 -2
- data/lib/igniter/runtime/node_state.rb +7 -2
- data/lib/igniter/runtime/resolver.rb +323 -31
- 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 +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
|
|
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
|