igniter 0.3.0 → 0.4.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/CHANGELOG.md +9 -0
- data/README.md +2 -2
- data/docs/API_V2.md +58 -0
- data/docs/DISTRIBUTED_CONTRACTS_V1.md +493 -0
- data/examples/README.md +3 -0
- data/examples/distributed_workflow.rb +52 -0
- data/examples/ringcentral_routing.rb +26 -35
- data/lib/igniter/compiler/compiled_graph.rb +20 -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/dependencies_validator.rb +43 -1
- data/lib/igniter/compiler/validators/remote_validator.rb +58 -0
- data/lib/igniter/compiler.rb +2 -0
- data/lib/igniter/contract.rb +75 -8
- data/lib/igniter/diagnostics/report.rb +102 -3
- data/lib/igniter/dsl/contract_builder.rb +109 -8
- data/lib/igniter/errors.rb +6 -1
- data/lib/igniter/extensions/introspection/graph_formatter.rb +4 -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/model/await_node.rb +21 -0
- data/lib/igniter/model/branch_node.rb +9 -3
- data/lib/igniter/model/collection_node.rb +9 -3
- data/lib/igniter/model/remote_node.rb +26 -0
- data/lib/igniter/model.rb +2 -0
- data/lib/igniter/runtime/execution.rb +2 -2
- data/lib/igniter/runtime/input_validator.rb +5 -3
- data/lib/igniter/runtime/resolver.rb +91 -8
- 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/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/version.rb +1 -1
- data/lib/igniter.rb +4 -0
- metadata +36 -2
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module LLM
|
|
5
|
+
module Providers
|
|
6
|
+
class Base
|
|
7
|
+
attr_reader :last_usage
|
|
8
|
+
|
|
9
|
+
def chat(messages:, model:, tools: [], **options)
|
|
10
|
+
raise NotImplementedError, "#{self.class}#chat must be implemented"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def complete(prompt:, model:, system: nil, **options)
|
|
14
|
+
messages = []
|
|
15
|
+
messages << { role: "system", content: system } if system
|
|
16
|
+
messages << { role: "user", content: prompt }
|
|
17
|
+
response = chat(messages: messages, model: model, **options)
|
|
18
|
+
response[:content]
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def record_usage(prompt_tokens: 0, completion_tokens: 0)
|
|
24
|
+
@last_usage = {
|
|
25
|
+
prompt_tokens: prompt_tokens,
|
|
26
|
+
completion_tokens: completion_tokens,
|
|
27
|
+
total_tokens: prompt_tokens + completion_tokens
|
|
28
|
+
}.freeze
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Igniter
|
|
8
|
+
module LLM
|
|
9
|
+
module Providers
|
|
10
|
+
# Ollama provider — calls the local Ollama REST API.
|
|
11
|
+
# Requires Ollama to be running: https://ollama.com
|
|
12
|
+
#
|
|
13
|
+
# Ollama API docs: https://github.com/ollama/ollama/blob/main/docs/api.md
|
|
14
|
+
class Ollama < Base
|
|
15
|
+
def initialize(base_url: "http://localhost:11434", timeout: 120)
|
|
16
|
+
super()
|
|
17
|
+
@base_url = base_url.chomp("/")
|
|
18
|
+
@timeout = timeout
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Send a chat completion request.
|
|
22
|
+
# Returns: { role: "assistant", content: "...", tool_calls: [...] }
|
|
23
|
+
def chat(messages:, model:, tools: [], **options) # rubocop:disable Metrics/MethodLength
|
|
24
|
+
body = {
|
|
25
|
+
model: model,
|
|
26
|
+
messages: normalize_messages(messages),
|
|
27
|
+
stream: false,
|
|
28
|
+
options: build_options(options)
|
|
29
|
+
}.compact
|
|
30
|
+
|
|
31
|
+
body[:tools] = normalize_tools(tools) if tools.any?
|
|
32
|
+
|
|
33
|
+
response = post("/api/chat", body)
|
|
34
|
+
|
|
35
|
+
message = response.fetch("message", {})
|
|
36
|
+
record_usage(
|
|
37
|
+
prompt_tokens: response["prompt_eval_count"] || 0,
|
|
38
|
+
completion_tokens: response["eval_count"] || 0
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
{
|
|
42
|
+
role: message.fetch("role", "assistant").to_sym,
|
|
43
|
+
content: message.fetch("content", ""),
|
|
44
|
+
tool_calls: parse_tool_calls(message["tool_calls"])
|
|
45
|
+
}
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def models
|
|
49
|
+
get("/api/tags").fetch("models", []).map { |m| m["name"] }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def post(path, body)
|
|
55
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
56
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
57
|
+
http.read_timeout = @timeout
|
|
58
|
+
http.open_timeout = 10
|
|
59
|
+
|
|
60
|
+
request = Net::HTTP::Post.new(uri.path, { "Content-Type" => "application/json" })
|
|
61
|
+
request.body = JSON.generate(body)
|
|
62
|
+
|
|
63
|
+
response = http.request(request)
|
|
64
|
+
handle_response(response)
|
|
65
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e
|
|
66
|
+
raise Igniter::LLM::ProviderError, "Cannot connect to Ollama at #{@base_url}: #{e.message}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def get(path)
|
|
70
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
71
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
72
|
+
http.open_timeout = 10
|
|
73
|
+
response = http.get(uri.path)
|
|
74
|
+
handle_response(response)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def handle_response(response)
|
|
78
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
79
|
+
raise Igniter::LLM::ProviderError,
|
|
80
|
+
"Ollama API error #{response.code}: #{response.body.to_s.slice(0, 200)}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
JSON.parse(response.body)
|
|
84
|
+
rescue JSON::ParserError => e
|
|
85
|
+
raise Igniter::LLM::ProviderError, "Ollama returned invalid JSON: #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def normalize_messages(messages)
|
|
89
|
+
messages.map do |msg|
|
|
90
|
+
{ "role" => msg[:role].to_s, "content" => msg[:content].to_s }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def normalize_tools(tools)
|
|
95
|
+
tools.map do |tool|
|
|
96
|
+
{
|
|
97
|
+
"type" => "function",
|
|
98
|
+
"function" => {
|
|
99
|
+
"name" => tool[:name].to_s,
|
|
100
|
+
"description" => tool[:description].to_s,
|
|
101
|
+
"parameters" => tool.fetch(:parameters, { type: "object", properties: {} })
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_tool_calls(raw)
|
|
108
|
+
return [] unless raw.is_a?(Array)
|
|
109
|
+
|
|
110
|
+
raw.map do |tc|
|
|
111
|
+
fn = tc["function"] || tc
|
|
112
|
+
{
|
|
113
|
+
name: fn["name"].to_s,
|
|
114
|
+
arguments: parse_arguments(fn["arguments"])
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse_arguments(args)
|
|
120
|
+
case args
|
|
121
|
+
when Hash then args.transform_keys(&:to_sym)
|
|
122
|
+
when String then JSON.parse(args).transform_keys(&:to_sym)
|
|
123
|
+
else {}
|
|
124
|
+
end
|
|
125
|
+
rescue JSON::ParserError
|
|
126
|
+
{}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def build_options(opts)
|
|
130
|
+
known = %i[temperature top_p top_k seed num_predict stop]
|
|
131
|
+
filtered = opts.slice(*known)
|
|
132
|
+
filtered.empty? ? nil : filtered.transform_keys(&:to_s)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module Igniter
|
|
8
|
+
module LLM
|
|
9
|
+
module Providers
|
|
10
|
+
# OpenAI provider (also compatible with Azure OpenAI and any OpenAI-compatible API).
|
|
11
|
+
# Requires OPENAI_API_KEY environment variable or explicit api_key:.
|
|
12
|
+
#
|
|
13
|
+
# API docs: https://platform.openai.com/docs/api-reference/chat
|
|
14
|
+
#
|
|
15
|
+
# Compatible with: OpenAI, Azure OpenAI, Groq, Together AI,
|
|
16
|
+
# Mistral, DeepSeek, and any OpenAI-compatible endpoint.
|
|
17
|
+
class OpenAI < Base # rubocop:disable Metrics/ClassLength
|
|
18
|
+
API_BASE = "https://api.openai.com"
|
|
19
|
+
|
|
20
|
+
def initialize(api_key: ENV["OPENAI_API_KEY"], base_url: API_BASE, timeout: 120)
|
|
21
|
+
super()
|
|
22
|
+
@api_key = api_key
|
|
23
|
+
@base_url = base_url.chomp("/")
|
|
24
|
+
@timeout = timeout
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Send a chat completion request.
|
|
28
|
+
def chat(messages:, model:, tools: [], **options) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
29
|
+
validate_api_key!
|
|
30
|
+
|
|
31
|
+
body = {
|
|
32
|
+
model: model,
|
|
33
|
+
messages: normalize_messages(messages)
|
|
34
|
+
}
|
|
35
|
+
body[:tools] = normalize_tools(tools) if tools.any?
|
|
36
|
+
body[:temperature] = options[:temperature] if options.key?(:temperature)
|
|
37
|
+
body[:top_p] = options[:top_p] if options.key?(:top_p)
|
|
38
|
+
body[:max_tokens] = options[:max_tokens] if options.key?(:max_tokens)
|
|
39
|
+
body[:seed] = options[:seed] if options.key?(:seed)
|
|
40
|
+
body[:stop] = options[:stop] if options.key?(:stop)
|
|
41
|
+
|
|
42
|
+
response = post("/v1/chat/completions", body)
|
|
43
|
+
parse_response(response)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def parse_response(response) # rubocop:disable Metrics/MethodLength
|
|
49
|
+
message = response.dig("choices", 0, "message") || {}
|
|
50
|
+
usage = response.fetch("usage", {})
|
|
51
|
+
|
|
52
|
+
record_usage(
|
|
53
|
+
prompt_tokens: usage["prompt_tokens"] || 0,
|
|
54
|
+
completion_tokens: usage["completion_tokens"] || 0
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
{
|
|
58
|
+
role: (message["role"] || "assistant").to_sym,
|
|
59
|
+
content: message["content"].to_s,
|
|
60
|
+
tool_calls: parse_tool_calls(message["tool_calls"])
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse_tool_calls(raw)
|
|
65
|
+
return [] unless raw.is_a?(Array)
|
|
66
|
+
|
|
67
|
+
raw.map do |tc|
|
|
68
|
+
fn = tc["function"] || {}
|
|
69
|
+
{
|
|
70
|
+
name: fn["name"].to_s,
|
|
71
|
+
arguments: parse_arguments(fn["arguments"])
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def parse_arguments(args)
|
|
77
|
+
case args
|
|
78
|
+
when Hash then args.transform_keys(&:to_sym)
|
|
79
|
+
when String then JSON.parse(args).transform_keys(&:to_sym)
|
|
80
|
+
else {}
|
|
81
|
+
end
|
|
82
|
+
rescue JSON::ParserError
|
|
83
|
+
{}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_messages(messages)
|
|
87
|
+
messages.map do |m|
|
|
88
|
+
{ "role" => (m[:role] || m["role"]).to_s, "content" => (m[:content] || m["content"]).to_s }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def normalize_tools(tools)
|
|
93
|
+
tools.map do |tool|
|
|
94
|
+
{
|
|
95
|
+
"type" => "function",
|
|
96
|
+
"function" => {
|
|
97
|
+
"name" => tool[:name].to_s,
|
|
98
|
+
"description" => tool[:description].to_s,
|
|
99
|
+
"parameters" => tool.fetch(:parameters) { { "type" => "object", "properties" => {} } }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def post(path, body) # rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
106
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
107
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
108
|
+
http.use_ssl = uri.scheme == "https"
|
|
109
|
+
http.read_timeout = @timeout
|
|
110
|
+
http.open_timeout = 10
|
|
111
|
+
|
|
112
|
+
request = Net::HTTP::Post.new(uri.path, headers)
|
|
113
|
+
request.body = JSON.generate(body)
|
|
114
|
+
|
|
115
|
+
response = http.request(request)
|
|
116
|
+
handle_response(response)
|
|
117
|
+
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL, SocketError, Net::OpenTimeout => e
|
|
118
|
+
raise Igniter::LLM::ProviderError, "Cannot connect to OpenAI API at #{@base_url}: #{e.message}"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def headers
|
|
122
|
+
{
|
|
123
|
+
"Content-Type" => "application/json",
|
|
124
|
+
"Authorization" => "Bearer #{@api_key}"
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def handle_response(response) # rubocop:disable Metrics/MethodLength
|
|
129
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
130
|
+
body = begin
|
|
131
|
+
JSON.parse(response.body)
|
|
132
|
+
rescue StandardError
|
|
133
|
+
{}
|
|
134
|
+
end
|
|
135
|
+
error_msg = body.dig("error", "message") || response.body.to_s.slice(0, 200)
|
|
136
|
+
raise Igniter::LLM::ProviderError, "OpenAI API error #{response.code}: #{error_msg}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
JSON.parse(response.body)
|
|
140
|
+
rescue JSON::ParserError => e
|
|
141
|
+
raise Igniter::LLM::ProviderError, "OpenAI returned invalid JSON: #{e.message}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_api_key!
|
|
145
|
+
return if @api_key && !@api_key.empty?
|
|
146
|
+
|
|
147
|
+
raise Igniter::LLM::ConfigurationError,
|
|
148
|
+
"OpenAI API key not configured. Set OPENAI_API_KEY or pass api_key: to the provider."
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require_relative "llm/config"
|
|
5
|
+
require_relative "llm/context"
|
|
6
|
+
require_relative "llm/providers/base"
|
|
7
|
+
require_relative "llm/providers/ollama"
|
|
8
|
+
require_relative "llm/providers/anthropic"
|
|
9
|
+
require_relative "llm/providers/openai"
|
|
10
|
+
require_relative "llm/executor"
|
|
11
|
+
|
|
12
|
+
module Igniter
|
|
13
|
+
module LLM
|
|
14
|
+
class Error < Igniter::Error; end
|
|
15
|
+
class ProviderError < Error; end
|
|
16
|
+
class ConfigurationError < Error; end
|
|
17
|
+
|
|
18
|
+
AVAILABLE_PROVIDERS = Config::PROVIDERS
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def config
|
|
22
|
+
@config ||= Config.new
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def configure
|
|
26
|
+
yield config
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns a memoized provider instance for the given provider name.
|
|
30
|
+
def provider_instance(name)
|
|
31
|
+
@provider_instances ||= {}
|
|
32
|
+
@provider_instances[name.to_sym] ||= build_provider(name.to_sym)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Reset cached provider instances (useful after reconfiguration).
|
|
36
|
+
def reset_providers!
|
|
37
|
+
@provider_instances = nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def build_provider(name)
|
|
43
|
+
case name
|
|
44
|
+
when :ollama
|
|
45
|
+
cfg = config.ollama
|
|
46
|
+
Providers::Ollama.new(base_url: cfg.base_url, timeout: cfg.timeout)
|
|
47
|
+
when :anthropic
|
|
48
|
+
cfg = config.anthropic
|
|
49
|
+
Providers::Anthropic.new(api_key: cfg.api_key, base_url: cfg.base_url, timeout: cfg.timeout)
|
|
50
|
+
when :openai
|
|
51
|
+
cfg = config.openai
|
|
52
|
+
Providers::OpenAI.new(api_key: cfg.api_key, base_url: cfg.base_url, timeout: cfg.timeout)
|
|
53
|
+
else
|
|
54
|
+
raise ConfigurationError, "Unknown LLM provider: #{name}. Available: #{AVAILABLE_PROVIDERS.inspect}"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Rails
|
|
5
|
+
# ActionCable channel mixin for streaming contract execution events.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# class OrderChannel < ApplicationCable::Channel
|
|
9
|
+
# include Igniter::Rails::CableAdapter
|
|
10
|
+
#
|
|
11
|
+
# subscribed do
|
|
12
|
+
# stream_contract(OrderContract, execution_id: params[:execution_id])
|
|
13
|
+
# end
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# Broadcasts events as:
|
|
17
|
+
# { type: "node_succeeded", node: "payment", status: "succeeded", payload: { ... } }
|
|
18
|
+
module CableAdapter
|
|
19
|
+
def stream_contract(contract_class, execution_id:, store: nil)
|
|
20
|
+
resolved_store = store || Igniter.execution_store
|
|
21
|
+
snapshot = resolved_store.fetch(execution_id)
|
|
22
|
+
instance = contract_class.restore(snapshot)
|
|
23
|
+
|
|
24
|
+
instance.subscribe do |event|
|
|
25
|
+
broadcast_igniter_event(event, execution_id)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@_igniter_executions ||= []
|
|
29
|
+
@_igniter_executions << instance
|
|
30
|
+
rescue Igniter::ResolutionError => e
|
|
31
|
+
transmit({ type: "error", message: e.message })
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def broadcast_igniter_event(event, execution_id)
|
|
37
|
+
transmit({
|
|
38
|
+
type: event.type.to_s,
|
|
39
|
+
execution_id: execution_id,
|
|
40
|
+
node: event.node_name,
|
|
41
|
+
path: event.path,
|
|
42
|
+
status: event.status,
|
|
43
|
+
payload: event.payload,
|
|
44
|
+
timestamp: event.timestamp&.iso8601
|
|
45
|
+
}.compact)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Rails
|
|
5
|
+
# Base ActiveJob class for async contract execution.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# class ProcessOrderJob < Igniter::Rails::ContractJob
|
|
9
|
+
# contract OrderContract
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# ProcessOrderJob.perform_later(order_id: "ord-123")
|
|
13
|
+
# ProcessOrderJob.perform_now(order_id: "ord-123")
|
|
14
|
+
#
|
|
15
|
+
# The job starts the contract and persists it to the configured store.
|
|
16
|
+
# If the contract has correlation keys, the execution can be resumed
|
|
17
|
+
# later via Contract.deliver_event.
|
|
18
|
+
class ContractJob
|
|
19
|
+
# No dependency on ActiveJob here — this class acts as a blueprint.
|
|
20
|
+
# When Rails is present, subclasses inherit from ApplicationJob automatically
|
|
21
|
+
# if the user adds `< ApplicationJob` (the recommended pattern).
|
|
22
|
+
|
|
23
|
+
class << self
|
|
24
|
+
def contract(klass = nil)
|
|
25
|
+
@contract_class = klass if klass
|
|
26
|
+
@contract_class
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def store(store_instance = nil)
|
|
30
|
+
@store = store_instance if store_instance
|
|
31
|
+
@store || Igniter.execution_store
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Wraps perform in an ActiveJob-compatible interface.
|
|
35
|
+
# Call this when Rails is available to get ActiveJob queueing.
|
|
36
|
+
def perform_later(**inputs)
|
|
37
|
+
if defined?(::ActiveJob::Base)
|
|
38
|
+
ActiveJobAdapter.perform_later(contract_class: contract, inputs: inputs, store: store)
|
|
39
|
+
else
|
|
40
|
+
perform_now(**inputs)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def perform_now(**inputs)
|
|
45
|
+
contract.start(inputs, store: store)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Included by ActiveJobAdapter to bridge ActiveJob lifecycle.
|
|
50
|
+
module Perform
|
|
51
|
+
def perform(contract_class_name:, inputs:, store_class: nil, store_config: nil)
|
|
52
|
+
klass = Object.const_get(contract_class_name)
|
|
53
|
+
resolved_store = resolve_store(store_class, store_config)
|
|
54
|
+
klass.start(inputs.transform_keys(&:to_sym), store: resolved_store)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def resolve_store(store_class, _store_config)
|
|
60
|
+
return Igniter.execution_store unless store_class
|
|
61
|
+
|
|
62
|
+
Object.const_get(store_class).new
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# ActiveJob adapter — only defined when ActiveJob is available.
|
|
68
|
+
if defined?(::ActiveJob::Base)
|
|
69
|
+
class ActiveJobAdapter < ::ActiveJob::Base
|
|
70
|
+
include ContractJob::Perform
|
|
71
|
+
|
|
72
|
+
queue_as :igniter
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Rails
|
|
7
|
+
module Generators
|
|
8
|
+
class ContractGenerator < ::Rails::Generators::NamedBase
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
desc "Creates an Igniter contract."
|
|
11
|
+
|
|
12
|
+
class_option :correlate_by, type: :array, default: [], desc: "Correlation key names"
|
|
13
|
+
class_option :inputs, type: :array, default: [], desc: "Input names"
|
|
14
|
+
class_option :outputs, type: :array, default: ["result"], desc: "Output names"
|
|
15
|
+
|
|
16
|
+
def create_contract
|
|
17
|
+
template "contract.rb.tt", File.join("app/contracts", class_path, "#{file_name}_contract.rb")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Rails
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
desc "Creates an Igniter initializer in your application."
|
|
11
|
+
|
|
12
|
+
def copy_initializer
|
|
13
|
+
template "igniter.rb.tt", "config/initializers/igniter.rb"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create_contracts_directory
|
|
17
|
+
empty_directory "app/contracts"
|
|
18
|
+
create_file "app/contracts/.keep"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def show_readme
|
|
22
|
+
say "", :green
|
|
23
|
+
say "✓ Igniter installed!", :green
|
|
24
|
+
say ""
|
|
25
|
+
say "Next steps:"
|
|
26
|
+
say " 1. Configure your store in config/initializers/igniter.rb"
|
|
27
|
+
say " 2. Generate a contract: rails g igniter:contract YourContractName"
|
|
28
|
+
say ""
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Rails
|
|
5
|
+
class Railtie < ::Rails::Railtie
|
|
6
|
+
initializer "igniter.configure_store" do
|
|
7
|
+
# Auto-configure store based on available adapters unless already set
|
|
8
|
+
next if Igniter.instance_variable_defined?(:@execution_store)
|
|
9
|
+
|
|
10
|
+
Igniter.execution_store =
|
|
11
|
+
if defined?(Redis) && ::Rails.application.config.respond_to?(:redis)
|
|
12
|
+
Igniter::Runtime::Stores::RedisStore.new(::Redis.current)
|
|
13
|
+
else
|
|
14
|
+
Igniter::Runtime::Stores::MemoryStore.new
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
initializer "igniter.load_contracts" do
|
|
19
|
+
::Rails.autoloaders.main.on_load("ApplicationContract") do
|
|
20
|
+
# Hook point for future eager loading of compiled contracts
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Rails
|
|
5
|
+
# Controller mixin for delivering external events to running contracts.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# class WebhooksController < ApplicationController
|
|
9
|
+
# include Igniter::Rails::WebhookHandler
|
|
10
|
+
#
|
|
11
|
+
# def stripe
|
|
12
|
+
# deliver_event_for(
|
|
13
|
+
# OrderContract,
|
|
14
|
+
# event: :stripe_payment_succeeded,
|
|
15
|
+
# correlation_from: { order_id: params[:metadata][:order_id] },
|
|
16
|
+
# payload: params.to_unsafe_h
|
|
17
|
+
# )
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
module WebhookHandler
|
|
21
|
+
def deliver_event_for(contract_class, event:, correlation_from:, payload: nil, store: nil) # rubocop:disable Metrics/MethodLength
|
|
22
|
+
payload_data = payload || (respond_to?(:params) ? params.to_unsafe_h : {})
|
|
23
|
+
correlation = extract_correlation(correlation_from)
|
|
24
|
+
|
|
25
|
+
contract_class.deliver_event(
|
|
26
|
+
event,
|
|
27
|
+
correlation: correlation,
|
|
28
|
+
payload: payload_data,
|
|
29
|
+
store: store || Igniter.execution_store
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
head :ok
|
|
33
|
+
rescue Igniter::ResolutionError => e
|
|
34
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def extract_correlation(source)
|
|
40
|
+
case source
|
|
41
|
+
when Hash then source.transform_keys(&:to_sym)
|
|
42
|
+
when Symbol then { source => params[source] }
|
|
43
|
+
when Array then source.each_with_object({}) { |k, h| h[k.to_sym] = params[k] }
|
|
44
|
+
else source.to_h.transform_keys(&:to_sym)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require_relative "rails/railtie" if defined?(::Rails::Railtie)
|
|
5
|
+
require_relative "rails/contract_job"
|
|
6
|
+
require_relative "rails/webhook_concern"
|
|
7
|
+
require_relative "rails/cable_adapter"
|
|
8
|
+
|
|
9
|
+
module Igniter
|
|
10
|
+
module Rails
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Model
|
|
5
|
+
class AwaitNode < Node
|
|
6
|
+
attr_reader :event_name
|
|
7
|
+
|
|
8
|
+
def initialize(id:, name:, event_name:, path: nil, metadata: {})
|
|
9
|
+
super(
|
|
10
|
+
id: id,
|
|
11
|
+
kind: :await,
|
|
12
|
+
name: name,
|
|
13
|
+
path: path || name.to_s,
|
|
14
|
+
dependencies: [],
|
|
15
|
+
metadata: metadata
|
|
16
|
+
)
|
|
17
|
+
@event_name = event_name.to_sym
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|