igniter 0.3.1 → 0.4.3
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/CHANGELOG.md +25 -0
- data/README.md +238 -218
- data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
- data/docs/LLM_V1.md +335 -0
- data/docs/PATTERNS.md +189 -0
- data/docs/SERVER_V1.md +313 -0
- data/examples/README.md +129 -0
- data/examples/agents.rb +150 -0
- data/examples/differential.rb +161 -0
- data/examples/distributed_server.rb +94 -0
- data/examples/distributed_workflow.rb +52 -0
- data/examples/effects.rb +184 -0
- data/examples/invariants.rb +179 -0
- data/examples/order_pipeline.rb +163 -0
- data/examples/provenance.rb +122 -0
- data/examples/saga.rb +110 -0
- data/lib/igniter/agent/mailbox.rb +96 -0
- data/lib/igniter/agent/message.rb +21 -0
- data/lib/igniter/agent/ref.rb +86 -0
- data/lib/igniter/agent/runner.rb +129 -0
- data/lib/igniter/agent/state_holder.rb +23 -0
- data/lib/igniter/agent.rb +155 -0
- data/lib/igniter/compiler/compiled_graph.rb +12 -0
- data/lib/igniter/compiler/validation_pipeline.rb +3 -1
- data/lib/igniter/compiler/validators/await_validator.rb +53 -0
- data/lib/igniter/compiler/validators/callable_validator.rb +21 -3
- data/lib/igniter/compiler/validators/dependencies_validator.rb +41 -1
- data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
- data/lib/igniter/compiler.rb +2 -0
- data/lib/igniter/contract.rb +59 -8
- data/lib/igniter/differential/divergence.rb +29 -0
- data/lib/igniter/differential/formatter.rb +96 -0
- data/lib/igniter/differential/report.rb +86 -0
- data/lib/igniter/differential/runner.rb +130 -0
- data/lib/igniter/differential.rb +51 -0
- data/lib/igniter/dsl/contract_builder.rb +74 -4
- data/lib/igniter/effect.rb +91 -0
- data/lib/igniter/effect_registry.rb +78 -0
- data/lib/igniter/errors.rb +17 -2
- data/lib/igniter/execution_report/builder.rb +54 -0
- data/lib/igniter/execution_report/formatter.rb +50 -0
- data/lib/igniter/execution_report/node_entry.rb +24 -0
- data/lib/igniter/execution_report/report.rb +65 -0
- data/lib/igniter/execution_report.rb +32 -0
- data/lib/igniter/extensions/differential.rb +114 -0
- data/lib/igniter/extensions/execution_report.rb +27 -0
- data/lib/igniter/extensions/invariants.rb +116 -0
- data/lib/igniter/extensions/provenance.rb +45 -0
- data/lib/igniter/extensions/saga.rb +74 -0
- data/lib/igniter/integrations/agents.rb +18 -0
- data/lib/igniter/integrations/llm/config.rb +69 -0
- data/lib/igniter/integrations/llm/context.rb +74 -0
- data/lib/igniter/integrations/llm/executor.rb +159 -0
- data/lib/igniter/integrations/llm/providers/anthropic.rb +148 -0
- data/lib/igniter/integrations/llm/providers/base.rb +33 -0
- data/lib/igniter/integrations/llm/providers/ollama.rb +137 -0
- data/lib/igniter/integrations/llm/providers/openai.rb +153 -0
- data/lib/igniter/integrations/llm.rb +59 -0
- data/lib/igniter/integrations/rails/cable_adapter.rb +49 -0
- data/lib/igniter/integrations/rails/contract_job.rb +76 -0
- data/lib/igniter/integrations/rails/generators/contract/contract_generator.rb +22 -0
- data/lib/igniter/integrations/rails/generators/install/install_generator.rb +33 -0
- data/lib/igniter/integrations/rails/railtie.rb +25 -0
- data/lib/igniter/integrations/rails/webhook_concern.rb +49 -0
- data/lib/igniter/integrations/rails.rb +12 -0
- data/lib/igniter/invariant.rb +50 -0
- data/lib/igniter/model/await_node.rb +21 -0
- data/lib/igniter/model/effect_node.rb +37 -0
- data/lib/igniter/model/remote_node.rb +26 -0
- data/lib/igniter/model.rb +3 -0
- data/lib/igniter/property_testing/formatter.rb +66 -0
- data/lib/igniter/property_testing/generators.rb +115 -0
- data/lib/igniter/property_testing/result.rb +45 -0
- data/lib/igniter/property_testing/run.rb +43 -0
- data/lib/igniter/property_testing/runner.rb +47 -0
- data/lib/igniter/property_testing.rb +64 -0
- data/lib/igniter/provenance/builder.rb +97 -0
- data/lib/igniter/provenance/lineage.rb +82 -0
- data/lib/igniter/provenance/node_trace.rb +65 -0
- data/lib/igniter/provenance/text_formatter.rb +70 -0
- data/lib/igniter/provenance.rb +29 -0
- data/lib/igniter/registry.rb +67 -0
- data/lib/igniter/runtime/execution.rb +2 -2
- data/lib/igniter/runtime/input_validator.rb +5 -3
- data/lib/igniter/runtime/resolver.rb +58 -1
- data/lib/igniter/runtime/stores/active_record_store.rb +13 -1
- data/lib/igniter/runtime/stores/file_store.rb +50 -2
- data/lib/igniter/runtime/stores/memory_store.rb +55 -2
- data/lib/igniter/runtime/stores/redis_store.rb +13 -1
- data/lib/igniter/saga/compensation.rb +31 -0
- data/lib/igniter/saga/compensation_record.rb +20 -0
- data/lib/igniter/saga/executor.rb +85 -0
- data/lib/igniter/saga/formatter.rb +49 -0
- data/lib/igniter/saga/result.rb +47 -0
- data/lib/igniter/saga.rb +56 -0
- data/lib/igniter/server/client.rb +123 -0
- data/lib/igniter/server/config.rb +27 -0
- data/lib/igniter/server/handlers/base.rb +105 -0
- data/lib/igniter/server/handlers/contracts_handler.rb +15 -0
- data/lib/igniter/server/handlers/event_handler.rb +28 -0
- data/lib/igniter/server/handlers/execute_handler.rb +37 -0
- data/lib/igniter/server/handlers/health_handler.rb +32 -0
- data/lib/igniter/server/handlers/status_handler.rb +27 -0
- data/lib/igniter/server/http_server.rb +109 -0
- data/lib/igniter/server/rack_app.rb +35 -0
- data/lib/igniter/server/registry.rb +56 -0
- data/lib/igniter/server/router.rb +75 -0
- data/lib/igniter/server.rb +67 -0
- data/lib/igniter/stream_loop.rb +80 -0
- data/lib/igniter/supervisor.rb +167 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +14 -0
- metadata +92 -2
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
class Agent
|
|
5
|
+
# Runs an agent's message loop in a dedicated Ruby Thread.
|
|
6
|
+
#
|
|
7
|
+
# Responsibilities:
|
|
8
|
+
# - Pop messages from the Mailbox and dispatch to registered handlers
|
|
9
|
+
# - Fire scheduled timers when their interval elapses
|
|
10
|
+
# - Apply handler return-value semantics to StateHolder
|
|
11
|
+
# - Invoke the on_crash callback when the thread dies unexpectedly
|
|
12
|
+
# - Fire lifecycle hooks (after_start, after_crash, after_stop)
|
|
13
|
+
#
|
|
14
|
+
# Handler return-value semantics:
|
|
15
|
+
# Hash → replace agent state with the returned hash
|
|
16
|
+
# :stop → close the mailbox and exit the loop cleanly
|
|
17
|
+
# nil → leave state unchanged (no reply sent to sync caller)
|
|
18
|
+
# other → leave state unchanged; if message has reply_to, send value as reply
|
|
19
|
+
class Runner
|
|
20
|
+
def initialize(agent_class:, mailbox:, state_holder:, on_crash: nil)
|
|
21
|
+
@agent_class = agent_class
|
|
22
|
+
@mailbox = mailbox
|
|
23
|
+
@state_holder = state_holder
|
|
24
|
+
@on_crash = on_crash
|
|
25
|
+
@thread = nil
|
|
26
|
+
@timers = build_timers
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Start the message loop in a background thread. Returns the Thread.
|
|
30
|
+
def start
|
|
31
|
+
@thread = Thread.new { run_loop }
|
|
32
|
+
@thread.abort_on_exception = false
|
|
33
|
+
@thread
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
attr_reader :thread
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def run_loop # rubocop:disable Metrics/MethodLength
|
|
41
|
+
fire_hooks(:start)
|
|
42
|
+
loop do
|
|
43
|
+
delay = nearest_timer_delay
|
|
44
|
+
message = @mailbox.pop(timeout: delay)
|
|
45
|
+
fire_due_timers
|
|
46
|
+
break if message.nil? && @mailbox.closed?
|
|
47
|
+
|
|
48
|
+
dispatch(message) if message
|
|
49
|
+
end
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
@on_crash&.call(e)
|
|
52
|
+
fire_hooks(:crash, error: e)
|
|
53
|
+
ensure
|
|
54
|
+
fire_hooks(:stop)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def dispatch(message) # rubocop:disable Metrics/MethodLength
|
|
58
|
+
handler = @agent_class.handlers[message.type]
|
|
59
|
+
|
|
60
|
+
unless handler
|
|
61
|
+
# Unknown message type — send nil reply if caller is waiting
|
|
62
|
+
send_reply(message, nil)
|
|
63
|
+
return
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
state = @state_holder.get
|
|
67
|
+
result = handler.call(state: state, payload: message.payload)
|
|
68
|
+
|
|
69
|
+
case result
|
|
70
|
+
when Hash
|
|
71
|
+
@state_holder.set(result)
|
|
72
|
+
send_reply(message, nil)
|
|
73
|
+
when :stop
|
|
74
|
+
send_reply(message, nil)
|
|
75
|
+
@mailbox.close
|
|
76
|
+
when nil
|
|
77
|
+
send_reply(message, nil)
|
|
78
|
+
else
|
|
79
|
+
# Non-state return value — treat as sync reply payload
|
|
80
|
+
send_reply(message, result)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def send_reply(message, value)
|
|
85
|
+
return unless message.reply_to
|
|
86
|
+
|
|
87
|
+
message.reply_to.push(
|
|
88
|
+
Message.new(type: :reply, payload: { value: value })
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns seconds until the next timer fires, or nil if no timers.
|
|
93
|
+
def nearest_timer_delay
|
|
94
|
+
return nil if @timers.empty?
|
|
95
|
+
|
|
96
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
|
+
next_at = @timers.map { |t| t[:next_at] }.min
|
|
98
|
+
[next_at - now, 0].max
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def fire_due_timers
|
|
102
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
103
|
+
@timers.each do |timer|
|
|
104
|
+
next if timer[:next_at] > now
|
|
105
|
+
|
|
106
|
+
state = @state_holder.get
|
|
107
|
+
result = timer[:handler].call(state: state)
|
|
108
|
+
@state_holder.set(result) if result.is_a?(Hash)
|
|
109
|
+
timer[:next_at] = now + timer[:interval]
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def build_timers
|
|
114
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
115
|
+
@agent_class.timers.map do |t|
|
|
116
|
+
t.merge(next_at: now + t[:interval])
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def fire_hooks(type, **args)
|
|
121
|
+
@agent_class.hooks[type]&.each do |hook|
|
|
122
|
+
hook.call(**args)
|
|
123
|
+
rescue StandardError
|
|
124
|
+
nil # hooks must not crash the runner
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
class Agent
|
|
5
|
+
# Mutex-guarded wrapper around an agent's current state hash.
|
|
6
|
+
# The state is always stored as a frozen Hash so callers can read it
|
|
7
|
+
# without holding the lock.
|
|
8
|
+
class StateHolder
|
|
9
|
+
def initialize(initial_state)
|
|
10
|
+
@state = initial_state.freeze
|
|
11
|
+
@mutex = Mutex.new
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def get
|
|
15
|
+
@mutex.synchronize { @state }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def set(new_state)
|
|
19
|
+
@mutex.synchronize { @state = new_state.freeze }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "agent/message"
|
|
4
|
+
require_relative "agent/mailbox"
|
|
5
|
+
require_relative "agent/state_holder"
|
|
6
|
+
require_relative "agent/runner"
|
|
7
|
+
require_relative "agent/ref"
|
|
8
|
+
|
|
9
|
+
module Igniter
|
|
10
|
+
# Base class for stateful, message-driven actors.
|
|
11
|
+
#
|
|
12
|
+
# Subclass Agent and use the class-level DSL to declare:
|
|
13
|
+
# - `initial_state` — default state hash for new instances
|
|
14
|
+
# - `on` — handler for a named message type
|
|
15
|
+
# - `schedule` — recurring timer handler
|
|
16
|
+
# - `mailbox_size` — queue capacity (default: 256)
|
|
17
|
+
# - `mailbox_overflow` — policy when queue is full (:block/:drop_oldest/
|
|
18
|
+
# :drop_newest/:error, default: :block)
|
|
19
|
+
# - `after_start` — hook called when the agent thread begins
|
|
20
|
+
# - `after_crash` — hook called with the error when the thread crashes
|
|
21
|
+
# - `after_stop` — hook called when the agent thread exits
|
|
22
|
+
#
|
|
23
|
+
# Handler return-value semantics:
|
|
24
|
+
# Hash → new state (replaces current state)
|
|
25
|
+
# :stop → shut down the agent cleanly
|
|
26
|
+
# nil → unchanged state
|
|
27
|
+
# other → unchanged state; sent as reply to sync `call()` callers
|
|
28
|
+
#
|
|
29
|
+
# Example:
|
|
30
|
+
#
|
|
31
|
+
# class CounterAgent < Igniter::Agent
|
|
32
|
+
# initial_state counter: 0
|
|
33
|
+
#
|
|
34
|
+
# on :increment do |state:, payload:, **|
|
|
35
|
+
# state.merge(counter: state[:counter] + payload.fetch(:by, 1))
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# on :count do |state:, **|
|
|
39
|
+
# state[:counter] # returned as sync reply
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# ref = CounterAgent.start
|
|
44
|
+
# ref.send(:increment, by: 5)
|
|
45
|
+
# ref.call(:count) # => 5
|
|
46
|
+
# ref.stop
|
|
47
|
+
#
|
|
48
|
+
class Agent
|
|
49
|
+
# ── Custom error types ────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
class MailboxFullError < Igniter::Error; end
|
|
52
|
+
class TimeoutError < Igniter::Error; end
|
|
53
|
+
|
|
54
|
+
# ── Class-level defaults ──────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
@handlers = {}
|
|
57
|
+
@timers = []
|
|
58
|
+
@default_state = {}
|
|
59
|
+
@mailbox_capacity = Mailbox::DEFAULT_CAPACITY
|
|
60
|
+
@mailbox_overflow = :block
|
|
61
|
+
@hooks = { start: [], crash: [], stop: [] }
|
|
62
|
+
|
|
63
|
+
class << self
|
|
64
|
+
attr_reader :handlers, :timers, :mailbox_capacity, :hooks
|
|
65
|
+
|
|
66
|
+
# ── Inheritance ─────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
def inherited(subclass)
|
|
69
|
+
super
|
|
70
|
+
subclass.instance_variable_set(:@handlers, {})
|
|
71
|
+
subclass.instance_variable_set(:@timers, [])
|
|
72
|
+
subclass.instance_variable_set(:@default_state, {})
|
|
73
|
+
subclass.instance_variable_set(:@mailbox_capacity, Mailbox::DEFAULT_CAPACITY)
|
|
74
|
+
subclass.instance_variable_set(:@mailbox_overflow, :block)
|
|
75
|
+
subclass.instance_variable_set(:@hooks, { start: [], crash: [], stop: [] })
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# ── DSL ─────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
# Set the initial state hash. Pass a plain Hash or a block returning one.
|
|
81
|
+
def initial_state(hash = nil, &block)
|
|
82
|
+
if block
|
|
83
|
+
@default_state_proc = block
|
|
84
|
+
else
|
|
85
|
+
@default_state = (hash || {}).freeze
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Return the resolved default state (evaluated fresh when a block was given).
|
|
90
|
+
def default_state
|
|
91
|
+
@default_state_proc ? @default_state_proc.call : @default_state
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Register a handler for messages of the given +type+.
|
|
95
|
+
def on(type, &handler)
|
|
96
|
+
@handlers[type.to_sym] = handler
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Register a recurring timer. The handler is called every +every+ seconds.
|
|
100
|
+
# Returning a Hash from the handler updates state; nil leaves it unchanged.
|
|
101
|
+
def schedule(name, every:, &handler)
|
|
102
|
+
@timers << { name: name.to_sym, interval: every.to_f, handler: handler }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Maximum number of messages in the mailbox before overflow policy applies.
|
|
106
|
+
def mailbox_size(capacity)
|
|
107
|
+
@mailbox_capacity = capacity
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Overflow policy when the mailbox is full.
|
|
111
|
+
# One of: :block (default), :drop_oldest, :drop_newest, :error
|
|
112
|
+
def mailbox_overflow(policy)
|
|
113
|
+
@mailbox_overflow = policy
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def after_start(&hook)
|
|
117
|
+
@hooks[:start] << hook
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def after_crash(&hook)
|
|
121
|
+
@hooks[:crash] << hook
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def after_stop(&hook)
|
|
125
|
+
@hooks[:stop] << hook
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# ── Factory ─────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
# Start the agent and return a Ref. The agent runs in a background Thread.
|
|
131
|
+
#
|
|
132
|
+
# Options:
|
|
133
|
+
# initial_state: Hash — override class-level default state
|
|
134
|
+
# on_crash: callable(error) — supervisor crash hook
|
|
135
|
+
# name: Symbol/String — register in Igniter::Registry under this name
|
|
136
|
+
#
|
|
137
|
+
def start(initial_state: nil, on_crash: nil, name: nil) # rubocop:disable Metrics/MethodLength
|
|
138
|
+
state_holder = StateHolder.new(initial_state || default_state)
|
|
139
|
+
mailbox = Mailbox.new(capacity: mailbox_capacity, overflow: @mailbox_overflow)
|
|
140
|
+
runner = Runner.new(
|
|
141
|
+
agent_class: self,
|
|
142
|
+
mailbox: mailbox,
|
|
143
|
+
state_holder: state_holder,
|
|
144
|
+
on_crash: on_crash
|
|
145
|
+
)
|
|
146
|
+
thread = runner.start
|
|
147
|
+
ref = Ref.new(thread: thread, mailbox: mailbox, state_holder: state_holder)
|
|
148
|
+
|
|
149
|
+
Igniter::Registry.register!(name, ref) if name
|
|
150
|
+
|
|
151
|
+
ref
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -49,6 +49,10 @@ module Igniter
|
|
|
49
49
|
raise KeyError, "Unknown dependency '#{name}'"
|
|
50
50
|
end
|
|
51
51
|
|
|
52
|
+
def await_nodes
|
|
53
|
+
@nodes.select { |n| n.kind == :await }
|
|
54
|
+
end
|
|
55
|
+
|
|
52
56
|
def to_h
|
|
53
57
|
{
|
|
54
58
|
name: name,
|
|
@@ -80,6 +84,7 @@ module Igniter
|
|
|
80
84
|
base[:mode] = node.mode
|
|
81
85
|
base[:mapper] = node.input_mapper.to_s if node.input_mapper?
|
|
82
86
|
end
|
|
87
|
+
base[:event] = node.event_name if node.kind == :await
|
|
83
88
|
base
|
|
84
89
|
end,
|
|
85
90
|
outputs: outputs.map do |output|
|
|
@@ -117,6 +122,13 @@ module Igniter
|
|
|
117
122
|
metadata: node.metadata.reject { |key, _| key == :source_location }
|
|
118
123
|
}
|
|
119
124
|
end,
|
|
125
|
+
awaits: nodes.select { |node| node.kind == :await }.map do |node|
|
|
126
|
+
{
|
|
127
|
+
name: node.name,
|
|
128
|
+
event: node.event_name,
|
|
129
|
+
metadata: node.metadata.reject { |key, _| key == :source_location }
|
|
130
|
+
}
|
|
131
|
+
end,
|
|
120
132
|
branches: nodes.select { |node| node.kind == :branch }.map do |node|
|
|
121
133
|
{
|
|
122
134
|
name: node.name,
|
|
@@ -8,7 +8,9 @@ module Igniter
|
|
|
8
8
|
Validators::OutputsValidator,
|
|
9
9
|
Validators::DependenciesValidator,
|
|
10
10
|
Validators::TypeCompatibilityValidator,
|
|
11
|
-
Validators::CallableValidator
|
|
11
|
+
Validators::CallableValidator,
|
|
12
|
+
Validators::AwaitValidator,
|
|
13
|
+
Validators::RemoteValidator
|
|
12
14
|
].freeze
|
|
13
15
|
|
|
14
16
|
def self.call(context, validators: DEFAULT_VALIDATORS)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Compiler
|
|
5
|
+
module Validators
|
|
6
|
+
class AwaitValidator
|
|
7
|
+
def self.call(context)
|
|
8
|
+
new(context).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(context)
|
|
12
|
+
@context = context
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
await_nodes = @context.runtime_nodes.select { |n| n.kind == :await }
|
|
17
|
+
return if await_nodes.empty?
|
|
18
|
+
|
|
19
|
+
validate_correlation_keys_as_inputs!(await_nodes)
|
|
20
|
+
validate_unique_event_names!(await_nodes)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def validate_correlation_keys_as_inputs!(await_nodes) # rubocop:disable Metrics/AbcSize
|
|
26
|
+
correlation_keys = @context.graph.metadata[:correlation_keys] || []
|
|
27
|
+
return if correlation_keys.empty?
|
|
28
|
+
|
|
29
|
+
input_names = @context.runtime_nodes.select { |n| n.kind == :input }.map(&:name)
|
|
30
|
+
missing = correlation_keys.reject { |key| input_names.include?(key.to_sym) }
|
|
31
|
+
return if missing.empty?
|
|
32
|
+
|
|
33
|
+
raise @context.validation_error(
|
|
34
|
+
await_nodes.first,
|
|
35
|
+
"Correlation keys #{missing.inspect} must be declared as inputs"
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_unique_event_names!(await_nodes)
|
|
40
|
+
event_names = await_nodes.map(&:event_name)
|
|
41
|
+
duplicates = event_names.select { |e| event_names.count(e) > 1 }.uniq
|
|
42
|
+
return if duplicates.empty?
|
|
43
|
+
|
|
44
|
+
node = await_nodes.find { |n| duplicates.include?(n.event_name) }
|
|
45
|
+
raise @context.validation_error(
|
|
46
|
+
node,
|
|
47
|
+
"Duplicate await event names: #{duplicates.inspect}"
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -14,14 +14,32 @@ module Igniter
|
|
|
14
14
|
|
|
15
15
|
def call
|
|
16
16
|
@context.runtime_nodes.each do |node|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
if node.kind == :compute
|
|
18
|
+
validate_callable_signature!(node)
|
|
19
|
+
elsif node.kind == :effect
|
|
20
|
+
validate_effect_adapter!(node)
|
|
21
|
+
end
|
|
20
22
|
end
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
private
|
|
24
26
|
|
|
27
|
+
def validate_effect_adapter!(node)
|
|
28
|
+
adapter = node.adapter_class
|
|
29
|
+
unless adapter.is_a?(Class) && adapter <= Igniter::Effect
|
|
30
|
+
raise @context.validation_error(
|
|
31
|
+
node,
|
|
32
|
+
"Effect '#{node.name}' adapter must be a subclass of Igniter::Effect"
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
validate_parameters_signature!(
|
|
37
|
+
node,
|
|
38
|
+
adapter.instance_method(:call).parameters,
|
|
39
|
+
adapter.name || "effect"
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
25
43
|
def validate_callable_signature!(node)
|
|
26
44
|
callable = node.callable
|
|
27
45
|
|
|
@@ -12,8 +12,10 @@ module Igniter
|
|
|
12
12
|
@context = context
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def call
|
|
15
|
+
def call # rubocop:disable Metrics/AbcSize,Metrics/CyclomaticComplexity
|
|
16
16
|
@context.runtime_nodes.each do |node|
|
|
17
|
+
next if node.kind == :await
|
|
18
|
+
|
|
17
19
|
validate_composition_node!(node) if node.kind == :composition
|
|
18
20
|
validate_branch_node!(node) if node.kind == :branch
|
|
19
21
|
validate_collection_node!(node) if node.kind == :collection
|
|
@@ -40,6 +42,7 @@ module Igniter
|
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
validate_composition_input_mapping!(node, contract_class.compiled_graph)
|
|
45
|
+
validate_composition_cycle!(node)
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
def validate_composition_input_mapping!(node, child_graph)
|
|
@@ -126,6 +129,43 @@ module Igniter
|
|
|
126
129
|
)
|
|
127
130
|
end
|
|
128
131
|
|
|
132
|
+
def validate_composition_cycle!(node)
|
|
133
|
+
child_contract = node.contract_class
|
|
134
|
+
return unless child_contract.respond_to?(:compiled_graph) && child_contract.compiled_graph
|
|
135
|
+
|
|
136
|
+
current_name = @context.graph.name
|
|
137
|
+
# Skip anonymous contracts to avoid false positives when multiple
|
|
138
|
+
# anonymous contracts share the same name "AnonymousContract"
|
|
139
|
+
return if current_name == "AnonymousContract"
|
|
140
|
+
|
|
141
|
+
validate_direct_cycle!(node, child_contract, current_name)
|
|
142
|
+
validate_grandchild_cycles!(node, child_contract, current_name)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def validate_direct_cycle!(node, child_contract, current_name)
|
|
146
|
+
return unless child_contract.compiled_graph.name == current_name
|
|
147
|
+
|
|
148
|
+
raise @context.validation_error(
|
|
149
|
+
node,
|
|
150
|
+
"Composition cycle: '#{node.name}' composes '#{child_contract.name}' " \
|
|
151
|
+
"which is the same contract ('#{current_name}')"
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def validate_grandchild_cycles!(node, child_contract, current_name) # rubocop:disable Metrics/AbcSize
|
|
156
|
+
child_contract.compiled_graph.nodes.select { |n| n.kind == :composition }.each do |grandchild|
|
|
157
|
+
next unless grandchild.contract_class.respond_to?(:compiled_graph)
|
|
158
|
+
next unless grandchild.contract_class.compiled_graph
|
|
159
|
+
next unless grandchild.contract_class.compiled_graph.name == current_name
|
|
160
|
+
|
|
161
|
+
raise @context.validation_error(
|
|
162
|
+
node,
|
|
163
|
+
"Composition cycle: '#{node.name}' -> '#{child_contract.name}' -> " \
|
|
164
|
+
"'#{grandchild.contract_class.name}' loops back to '#{current_name}'"
|
|
165
|
+
)
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
129
169
|
def validate_collection_node!(node)
|
|
130
170
|
unless node.contract_class.is_a?(Class) && node.contract_class <= Igniter::Contract
|
|
131
171
|
raise @context.validation_error(node, "Collection '#{node.name}' must reference an Igniter::Contract subclass")
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Compiler
|
|
5
|
+
module Validators
|
|
6
|
+
class RemoteValidator
|
|
7
|
+
def self.call(context)
|
|
8
|
+
new(context).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(context)
|
|
12
|
+
@context = context
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call
|
|
16
|
+
@context.runtime_nodes.each do |node|
|
|
17
|
+
next unless node.kind == :remote
|
|
18
|
+
|
|
19
|
+
validate_url!(node)
|
|
20
|
+
validate_contract_name!(node)
|
|
21
|
+
validate_dependencies!(node)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def validate_url!(node)
|
|
28
|
+
return if node.node_url.start_with?("http://", "https://")
|
|
29
|
+
|
|
30
|
+
raise @context.validation_error(
|
|
31
|
+
node,
|
|
32
|
+
"remote :#{node.name} has invalid node: URL '#{node.node_url}'. Must start with http:// or https://"
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_contract_name!(node)
|
|
37
|
+
return unless node.contract_name.strip.empty?
|
|
38
|
+
|
|
39
|
+
raise @context.validation_error(
|
|
40
|
+
node,
|
|
41
|
+
"remote :#{node.name} requires a non-empty contract: name"
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def validate_dependencies!(node)
|
|
46
|
+
node.dependencies.each do |dep_name|
|
|
47
|
+
next if @context.dependency_resolvable?(dep_name)
|
|
48
|
+
|
|
49
|
+
raise @context.validation_error(
|
|
50
|
+
node,
|
|
51
|
+
"remote :#{node.name} depends on '#{dep_name}' which is not defined in the graph"
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/igniter/compiler.rb
CHANGED
|
@@ -8,6 +8,8 @@ require_relative "compiler/validators/outputs_validator"
|
|
|
8
8
|
require_relative "compiler/validators/dependencies_validator"
|
|
9
9
|
require_relative "compiler/validators/callable_validator"
|
|
10
10
|
require_relative "compiler/validators/type_compatibility_validator"
|
|
11
|
+
require_relative "compiler/validators/await_validator"
|
|
12
|
+
require_relative "compiler/validators/remote_validator"
|
|
11
13
|
require_relative "compiler/validation_pipeline"
|
|
12
14
|
require_relative "compiler/validator"
|
|
13
15
|
require_relative "compiler/graph_compiler"
|
data/lib/igniter/contract.rb
CHANGED
|
@@ -3,8 +3,20 @@
|
|
|
3
3
|
module Igniter
|
|
4
4
|
class Contract
|
|
5
5
|
class << self
|
|
6
|
+
def correlate_by(*keys)
|
|
7
|
+
@correlation_keys = keys.map(&:to_sym).freeze
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def correlation_keys
|
|
11
|
+
@correlation_keys || []
|
|
12
|
+
end
|
|
13
|
+
|
|
6
14
|
def define(&block)
|
|
7
|
-
@compiled_graph = DSL::ContractBuilder.compile(
|
|
15
|
+
@compiled_graph = DSL::ContractBuilder.compile(
|
|
16
|
+
name: contract_name,
|
|
17
|
+
correlation_keys: correlation_keys,
|
|
18
|
+
&block
|
|
19
|
+
)
|
|
8
20
|
end
|
|
9
21
|
|
|
10
22
|
def run_with(runner:, max_workers: nil)
|
|
@@ -28,6 +40,45 @@ module Igniter
|
|
|
28
40
|
@compiled_graph = DSL::SchemaBuilder.compile(schema, name: contract_name)
|
|
29
41
|
end
|
|
30
42
|
|
|
43
|
+
def start(inputs = {}, store: nil, **keyword_inputs)
|
|
44
|
+
resolved_store = store || Igniter.execution_store
|
|
45
|
+
all_inputs = inputs.merge(keyword_inputs)
|
|
46
|
+
|
|
47
|
+
instance = new(all_inputs, runner: :store, store: resolved_store)
|
|
48
|
+
instance.resolve_all
|
|
49
|
+
|
|
50
|
+
correlation = correlation_keys.each_with_object({}) do |key, hash|
|
|
51
|
+
hash[key] = all_inputs[key] || all_inputs[key.to_s]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
resolved_store.save(instance.snapshot, correlation: correlation.compact, graph: contract_name)
|
|
55
|
+
instance
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def deliver_event(event_name, correlation:, payload:, store: nil) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
59
|
+
resolved_store = store || Igniter.execution_store
|
|
60
|
+
execution_id = resolved_store.find_by_correlation(
|
|
61
|
+
graph: contract_name,
|
|
62
|
+
correlation: correlation.transform_keys(&:to_sym)
|
|
63
|
+
)
|
|
64
|
+
unless execution_id
|
|
65
|
+
raise ResolutionError,
|
|
66
|
+
"No pending execution found for #{contract_name} with given correlation"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
instance = restore_from_store(execution_id, store: resolved_store)
|
|
70
|
+
|
|
71
|
+
await_node = instance.execution.compiled_graph.await_nodes
|
|
72
|
+
.find { |n| n.event_name == event_name.to_sym }
|
|
73
|
+
raise ResolutionError, "No await node found for event '#{event_name}' in #{contract_name}" unless await_node
|
|
74
|
+
|
|
75
|
+
instance.execution.resume(await_node.name, value: payload)
|
|
76
|
+
instance.resolve_all
|
|
77
|
+
|
|
78
|
+
resolved_store.save(instance.snapshot, correlation: correlation.transform_keys(&:to_sym), graph: contract_name)
|
|
79
|
+
instance
|
|
80
|
+
end
|
|
81
|
+
|
|
31
82
|
def restore(snapshot)
|
|
32
83
|
instance = new(
|
|
33
84
|
snapshot[:inputs] || snapshot["inputs"] || {},
|
|
@@ -159,9 +210,9 @@ module Igniter
|
|
|
159
210
|
end
|
|
160
211
|
end
|
|
161
212
|
|
|
162
|
-
attr_reader :execution, :result
|
|
213
|
+
attr_reader :execution, :result, :reactive
|
|
163
214
|
|
|
164
|
-
def initialize(inputs = nil, runner: nil, max_workers: nil, **keyword_inputs)
|
|
215
|
+
def initialize(inputs = nil, runner: nil, max_workers: nil, store: nil, **keyword_inputs)
|
|
165
216
|
graph = self.class.compiled_graph
|
|
166
217
|
raise CompileError, "#{self.class.name} has no compiled graph. Use `define`." unless graph
|
|
167
218
|
|
|
@@ -175,7 +226,7 @@ module Igniter
|
|
|
175
226
|
end
|
|
176
227
|
|
|
177
228
|
execution_options = self.class.execution_options.merge(
|
|
178
|
-
{ runner: runner, max_workers: max_workers }.compact
|
|
229
|
+
{ runner: runner, max_workers: max_workers, store: store }.compact
|
|
179
230
|
)
|
|
180
231
|
execution_options[:store] ||= Igniter.execution_store if execution_options[:runner]&.to_sym == :store
|
|
181
232
|
|
|
@@ -220,10 +271,6 @@ module Igniter
|
|
|
220
271
|
execution.audit.snapshot
|
|
221
272
|
end
|
|
222
273
|
|
|
223
|
-
def reactive
|
|
224
|
-
@reactive
|
|
225
|
-
end
|
|
226
|
-
|
|
227
274
|
def subscribe(subscriber = nil, &block)
|
|
228
275
|
execution.events.subscribe(subscriber, &block)
|
|
229
276
|
self
|
|
@@ -261,5 +308,9 @@ module Igniter
|
|
|
261
308
|
def failed?
|
|
262
309
|
execution.failed?
|
|
263
310
|
end
|
|
311
|
+
|
|
312
|
+
def pending?
|
|
313
|
+
execution.pending?
|
|
314
|
+
end
|
|
264
315
|
end
|
|
265
316
|
end
|