igniter-extensions 0.5.2
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 +7 -0
- data/README.md +381 -0
- data/lib/igniter/extensions/contracts/aggregate_pack.rb +103 -0
- data/lib/igniter/extensions/contracts/audit/builder.rb +132 -0
- data/lib/igniter/extensions/contracts/audit/event.rb +34 -0
- data/lib/igniter/extensions/contracts/audit/snapshot.rb +44 -0
- data/lib/igniter/extensions/contracts/audit_pack.rb +60 -0
- data/lib/igniter/extensions/contracts/branch_pack.rb +199 -0
- data/lib/igniter/extensions/contracts/capabilities/declaration.rb +31 -0
- data/lib/igniter/extensions/contracts/capabilities/error.rb +35 -0
- data/lib/igniter/extensions/contracts/capabilities/policy.rb +20 -0
- data/lib/igniter/extensions/contracts/capabilities/report.rb +47 -0
- data/lib/igniter/extensions/contracts/capabilities/violation.rb +30 -0
- data/lib/igniter/extensions/contracts/capabilities_pack.rb +146 -0
- data/lib/igniter/extensions/contracts/collection_pack.rb +212 -0
- data/lib/igniter/extensions/contracts/commerce_pack.rb +91 -0
- data/lib/igniter/extensions/contracts/compose_pack.rb +213 -0
- data/lib/igniter/extensions/contracts/content_addressing/cache.rb +59 -0
- data/lib/igniter/extensions/contracts/content_addressing/content_key.rb +63 -0
- data/lib/igniter/extensions/contracts/content_addressing/declaration.rb +47 -0
- data/lib/igniter/extensions/contracts/content_addressing_pack.rb +90 -0
- data/lib/igniter/extensions/contracts/creator/profile.rb +196 -0
- data/lib/igniter/extensions/contracts/creator/report.rb +85 -0
- data/lib/igniter/extensions/contracts/creator/scaffold.rb +461 -0
- data/lib/igniter/extensions/contracts/creator/scope.rb +79 -0
- data/lib/igniter/extensions/contracts/creator/wizard.rb +269 -0
- data/lib/igniter/extensions/contracts/creator/workflow.rb +189 -0
- data/lib/igniter/extensions/contracts/creator/workflow_step.rb +51 -0
- data/lib/igniter/extensions/contracts/creator/write_result.rb +48 -0
- data/lib/igniter/extensions/contracts/creator/write_step.rb +63 -0
- data/lib/igniter/extensions/contracts/creator/writer.rb +131 -0
- data/lib/igniter/extensions/contracts/creator_pack.rb +128 -0
- data/lib/igniter/extensions/contracts/dataflow/aggregate_operators.rb +119 -0
- data/lib/igniter/extensions/contracts/dataflow/aggregate_state.rb +60 -0
- data/lib/igniter/extensions/contracts/dataflow/builder.rb +66 -0
- data/lib/igniter/extensions/contracts/dataflow/collection_result.rb +70 -0
- data/lib/igniter/extensions/contracts/dataflow/diff.rb +37 -0
- data/lib/igniter/extensions/contracts/dataflow/item_result.rb +44 -0
- data/lib/igniter/extensions/contracts/dataflow/result.rb +58 -0
- data/lib/igniter/extensions/contracts/dataflow/session.rb +173 -0
- data/lib/igniter/extensions/contracts/dataflow/window_filter.rb +49 -0
- data/lib/igniter/extensions/contracts/dataflow_pack.rb +66 -0
- data/lib/igniter/extensions/contracts/debug/pack_audit.rb +181 -0
- data/lib/igniter/extensions/contracts/debug/pack_snapshot.rb +46 -0
- data/lib/igniter/extensions/contracts/debug/profile_snapshot.rb +50 -0
- data/lib/igniter/extensions/contracts/debug/report.rb +50 -0
- data/lib/igniter/extensions/contracts/debug_pack.rb +115 -0
- data/lib/igniter/extensions/contracts/differential/divergence.rb +37 -0
- data/lib/igniter/extensions/contracts/differential/formatter.rb +85 -0
- data/lib/igniter/extensions/contracts/differential/report.rb +83 -0
- data/lib/igniter/extensions/contracts/differential/runner.rb +136 -0
- data/lib/igniter/extensions/contracts/differential_pack.rb +61 -0
- data/lib/igniter/extensions/contracts/execution_report_pack.rb +38 -0
- data/lib/igniter/extensions/contracts/incremental/formatter.rb +60 -0
- data/lib/igniter/extensions/contracts/incremental/node_state.rb +30 -0
- data/lib/igniter/extensions/contracts/incremental/result.rb +65 -0
- data/lib/igniter/extensions/contracts/incremental/session.rb +146 -0
- data/lib/igniter/extensions/contracts/incremental_pack.rb +40 -0
- data/lib/igniter/extensions/contracts/invariants/builder.rb +27 -0
- data/lib/igniter/extensions/contracts/invariants/cases_report.rb +47 -0
- data/lib/igniter/extensions/contracts/invariants/error.rb +34 -0
- data/lib/igniter/extensions/contracts/invariants/invariant.rb +30 -0
- data/lib/igniter/extensions/contracts/invariants/report.rb +45 -0
- data/lib/igniter/extensions/contracts/invariants/suite.rb +36 -0
- data/lib/igniter/extensions/contracts/invariants/violation.rb +39 -0
- data/lib/igniter/extensions/contracts/invariants_pack.rb +88 -0
- data/lib/igniter/extensions/contracts/journal_pack.rb +55 -0
- data/lib/igniter/extensions/contracts/language/formula_pack.rb +185 -0
- data/lib/igniter/extensions/contracts/language/piecewise_pack.rb +166 -0
- data/lib/igniter/extensions/contracts/language/scale_pack.rb +147 -0
- data/lib/igniter/extensions/contracts/lookup_pack.rb +50 -0
- data/lib/igniter/extensions/contracts/mcp/creator_session.rb +105 -0
- data/lib/igniter/extensions/contracts/mcp/tool_argument.rb +35 -0
- data/lib/igniter/extensions/contracts/mcp/tool_definition.rb +33 -0
- data/lib/igniter/extensions/contracts/mcp/tool_result.rb +28 -0
- data/lib/igniter/extensions/contracts/mcp_pack.rb +335 -0
- data/lib/igniter/extensions/contracts/provenance/builder.rb +80 -0
- data/lib/igniter/extensions/contracts/provenance/lineage.rb +59 -0
- data/lib/igniter/extensions/contracts/provenance/node_trace.rb +53 -0
- data/lib/igniter/extensions/contracts/provenance/text_formatter.rb +62 -0
- data/lib/igniter/extensions/contracts/provenance_pack.rb +52 -0
- data/lib/igniter/extensions/contracts/reactive/builder.rb +43 -0
- data/lib/igniter/extensions/contracts/reactive/dispatch_result.rb +59 -0
- data/lib/igniter/extensions/contracts/reactive/engine.rb +79 -0
- data/lib/igniter/extensions/contracts/reactive/event.rb +36 -0
- data/lib/igniter/extensions/contracts/reactive/matcher.rb +20 -0
- data/lib/igniter/extensions/contracts/reactive/plan.rb +58 -0
- data/lib/igniter/extensions/contracts/reactive/subscription.rb +29 -0
- data/lib/igniter/extensions/contracts/reactive_pack.rb +169 -0
- data/lib/igniter/extensions/contracts/saga/compensation.rb +25 -0
- data/lib/igniter/extensions/contracts/saga/compensation_record.rb +28 -0
- data/lib/igniter/extensions/contracts/saga/compensation_set.rb +47 -0
- data/lib/igniter/extensions/contracts/saga/formatter.rb +39 -0
- data/lib/igniter/extensions/contracts/saga/result.rb +56 -0
- data/lib/igniter/extensions/contracts/saga/runner.rb +124 -0
- data/lib/igniter/extensions/contracts/saga_pack.rb +56 -0
- data/lib/igniter/extensions/contracts.rb +445 -0
- data/lib/igniter/extensions.rb +6 -0
- data/lib/igniter-extensions.rb +3 -0
- metadata +152 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Extensions
|
|
5
|
+
module Contracts
|
|
6
|
+
module CollectionPack
|
|
7
|
+
INTERNAL_SOURCE = :__collection_items__
|
|
8
|
+
|
|
9
|
+
Invocation = Struct.new(:operation, :items, :inputs, :compiled_graph, :profile, :key_name, :window,
|
|
10
|
+
keyword_init: true) do
|
|
11
|
+
def initialize(operation:, items:, inputs:, compiled_graph:, profile:, key_name:, window:)
|
|
12
|
+
super(
|
|
13
|
+
operation: operation,
|
|
14
|
+
items: Array(items),
|
|
15
|
+
inputs: inputs.transform_keys(&:to_sym).freeze,
|
|
16
|
+
compiled_graph: compiled_graph,
|
|
17
|
+
profile: profile,
|
|
18
|
+
key_name: key_name.to_sym,
|
|
19
|
+
window: window
|
|
20
|
+
)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module LocalInvoker
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def call(invocation:)
|
|
28
|
+
environment = Igniter::Contracts::Environment.new(profile: invocation.profile)
|
|
29
|
+
session = Igniter::Extensions::Contracts::DataflowPack.session(
|
|
30
|
+
environment,
|
|
31
|
+
source: INTERNAL_SOURCE,
|
|
32
|
+
key: invocation.key_name,
|
|
33
|
+
context: invocation.inputs.keys,
|
|
34
|
+
window: invocation.window,
|
|
35
|
+
compiled_graph: invocation.compiled_graph
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
result = session.run(inputs: invocation.inputs.merge(INTERNAL_SOURCE => invocation.items))
|
|
39
|
+
result.processed
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
def manifest
|
|
45
|
+
Igniter::Contracts::PackManifest.new(
|
|
46
|
+
name: :extensions_collection,
|
|
47
|
+
node_contracts: [Igniter::Contracts::PackManifest.node(:collection)],
|
|
48
|
+
registry_contracts: [
|
|
49
|
+
Igniter::Contracts::PackManifest.validator(:collection_dependencies),
|
|
50
|
+
Igniter::Contracts::PackManifest.validator(:collection_contracts),
|
|
51
|
+
Igniter::Contracts::PackManifest.validator(:collection_invokers)
|
|
52
|
+
],
|
|
53
|
+
requires_packs: [DataflowPack, IncrementalPack],
|
|
54
|
+
metadata: { category: :orchestration },
|
|
55
|
+
provides_capabilities: %i[collection keyed_sessions incremental_collection]
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def install_into(kernel)
|
|
60
|
+
kernel.nodes.register(:collection,
|
|
61
|
+
Igniter::Contracts::NodeType.new(kind: :collection,
|
|
62
|
+
metadata: { category: :orchestration }))
|
|
63
|
+
kernel.dsl_keywords.register(:collection, collection_keyword)
|
|
64
|
+
kernel.validators.register(:collection_dependencies, method(:validate_collection_dependencies))
|
|
65
|
+
kernel.validators.register(:collection_contracts, method(:validate_collection_contracts))
|
|
66
|
+
kernel.validators.register(:collection_invokers, method(:validate_collection_invokers))
|
|
67
|
+
kernel.runtime_handlers.register(:collection, method(:handle_collection))
|
|
68
|
+
kernel
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def collection_keyword
|
|
72
|
+
Igniter::Contracts::DslKeyword.new(:collection) do |name, from:, key:, builder:, inputs: {}, window: nil, contract: nil, via: nil, &block|
|
|
73
|
+
compiled_graph = compile_contract(name: name, contract: contract, profile: builder.profile, block: block)
|
|
74
|
+
input_map = normalize_inputs(inputs)
|
|
75
|
+
source_name = from.to_sym
|
|
76
|
+
|
|
77
|
+
builder.add_operation(
|
|
78
|
+
kind: :collection,
|
|
79
|
+
name: name,
|
|
80
|
+
from: source_name,
|
|
81
|
+
key_name: key.to_sym,
|
|
82
|
+
depends_on: [source_name, *extract_dependencies(input_map)].uniq,
|
|
83
|
+
inputs: input_map,
|
|
84
|
+
window: window,
|
|
85
|
+
compiled_graph: compiled_graph,
|
|
86
|
+
invoker: via
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_collection_dependencies(operations:, profile: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
92
|
+
available = operations.reject(&:output?).map(&:name)
|
|
93
|
+
missing = operations.select { |operation| operation.kind == :collection }
|
|
94
|
+
.flat_map { |operation| Array(operation.attributes[:depends_on]) }
|
|
95
|
+
.map(&:to_sym)
|
|
96
|
+
.reject { |name| available.include?(name) }
|
|
97
|
+
.uniq
|
|
98
|
+
return [] if missing.empty?
|
|
99
|
+
|
|
100
|
+
[Igniter::Contracts::ValidationFinding.new(
|
|
101
|
+
code: :missing_collection_dependencies,
|
|
102
|
+
message: "collection dependencies are not defined: #{missing.map(&:to_s).join(", ")}",
|
|
103
|
+
subjects: missing
|
|
104
|
+
)]
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_collection_contracts(operations:, profile:)
|
|
108
|
+
collection_operations = operations.select { |operation| operation.kind == :collection }
|
|
109
|
+
findings = []
|
|
110
|
+
|
|
111
|
+
invalid_contracts = collection_operations.reject do |operation|
|
|
112
|
+
operation.attributes[:compiled_graph].is_a?(Igniter::Contracts::CompiledGraph)
|
|
113
|
+
end
|
|
114
|
+
if invalid_contracts.any?
|
|
115
|
+
findings << Igniter::Contracts::ValidationFinding.new(
|
|
116
|
+
code: :invalid_collection_contract,
|
|
117
|
+
message: "collection nodes require a compiled item graph: #{invalid_contracts.map(&:name).join(", ")}",
|
|
118
|
+
subjects: invalid_contracts.map(&:name)
|
|
119
|
+
)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
mismatched = collection_operations.select do |operation|
|
|
123
|
+
compiled_graph = operation.attributes[:compiled_graph]
|
|
124
|
+
compiled_graph.is_a?(Igniter::Contracts::CompiledGraph) &&
|
|
125
|
+
compiled_graph.profile_fingerprint != profile.fingerprint
|
|
126
|
+
end
|
|
127
|
+
if mismatched.any?
|
|
128
|
+
findings << Igniter::Contracts::ValidationFinding.new(
|
|
129
|
+
code: :collection_profile_mismatch,
|
|
130
|
+
message: "collection item graphs were compiled against a different profile: #{mismatched.map(&:name).join(", ")}",
|
|
131
|
+
subjects: mismatched.map(&:name)
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
findings
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_collection_invokers(operations:, profile: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
139
|
+
invalid = operations.select { |operation| operation.kind == :collection }
|
|
140
|
+
.reject do |operation|
|
|
141
|
+
invoker = operation.attributes[:invoker]
|
|
142
|
+
invoker.nil? || invoker.respond_to?(:call)
|
|
143
|
+
end
|
|
144
|
+
return [] if invalid.empty?
|
|
145
|
+
|
|
146
|
+
[Igniter::Contracts::ValidationFinding.new(
|
|
147
|
+
code: :invalid_collection_invoker,
|
|
148
|
+
message: "collection via: must be callable: #{invalid.map(&:name).join(", ")}",
|
|
149
|
+
subjects: invalid.map(&:name)
|
|
150
|
+
)]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def handle_collection(operation:, state:, profile:, **)
|
|
154
|
+
items = state.fetch(operation.attributes.fetch(:from))
|
|
155
|
+
invocation = Invocation.new(
|
|
156
|
+
operation: operation,
|
|
157
|
+
items: items,
|
|
158
|
+
inputs: resolve_inputs(operation, state: state),
|
|
159
|
+
compiled_graph: operation.attributes.fetch(:compiled_graph),
|
|
160
|
+
profile: profile,
|
|
161
|
+
key_name: operation.attributes.fetch(:key_name),
|
|
162
|
+
window: operation.attributes[:window]
|
|
163
|
+
)
|
|
164
|
+
invoker = operation.attributes[:invoker] || LocalInvoker
|
|
165
|
+
result = invoker.call(invocation: invocation)
|
|
166
|
+
unless result.is_a?(Igniter::Extensions::Contracts::Dataflow::CollectionResult)
|
|
167
|
+
raise Igniter::Contracts::Error,
|
|
168
|
+
"collection invoker for #{operation.name} must return a CollectionResult"
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
result
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def compile_contract(name:, contract:, profile:, block:)
|
|
177
|
+
if contract && block
|
|
178
|
+
raise ArgumentError,
|
|
179
|
+
"collection :#{name} accepts either contract: or a block, not both"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
source = contract || block
|
|
183
|
+
raise ArgumentError, "collection :#{name} requires contract: or a block" unless source
|
|
184
|
+
|
|
185
|
+
return source if source.is_a?(Igniter::Contracts::CompiledGraph)
|
|
186
|
+
return Igniter::Contracts.compile(profile: profile, &source) if source.respond_to?(:call)
|
|
187
|
+
|
|
188
|
+
raise ArgumentError, "collection :#{name} contract must be a compiled graph or callable"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def normalize_inputs(inputs)
|
|
192
|
+
raise ArgumentError, "collection inputs: must be a Hash" unless inputs.is_a?(Hash)
|
|
193
|
+
|
|
194
|
+
inputs.each_with_object({}) do |(key, value), memo|
|
|
195
|
+
memo[key.to_sym] = value
|
|
196
|
+
end.freeze
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def extract_dependencies(input_map)
|
|
200
|
+
input_map.values.grep(Symbol).map(&:to_sym).uniq
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def resolve_inputs(operation, state:)
|
|
204
|
+
operation.attributes.fetch(:inputs).each_with_object({}) do |(key, source), memo|
|
|
205
|
+
memo[key.to_sym] = source.is_a?(Symbol) ? state.fetch(source.to_sym) : source
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Extensions
|
|
5
|
+
module Contracts
|
|
6
|
+
module CommercePack
|
|
7
|
+
class << self
|
|
8
|
+
def manifest
|
|
9
|
+
Igniter::Contracts::PackManifest.new(
|
|
10
|
+
name: :extensions_commerce,
|
|
11
|
+
requires_packs: [LookupPack, AggregatePack],
|
|
12
|
+
registry_contracts: [
|
|
13
|
+
Igniter::Contracts::PackManifest.dsl_keyword(:order_items),
|
|
14
|
+
Igniter::Contracts::PackManifest.dsl_keyword(:subtotal),
|
|
15
|
+
Igniter::Contracts::PackManifest.dsl_keyword(:tax_amount),
|
|
16
|
+
Igniter::Contracts::PackManifest.dsl_keyword(:grand_total)
|
|
17
|
+
]
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def install_into(kernel)
|
|
22
|
+
install_dsl_keywords(kernel)
|
|
23
|
+
kernel
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def install_dsl_keywords(kernel)
|
|
27
|
+
kernel.dsl_keywords.register(:order_items, order_items_keyword)
|
|
28
|
+
kernel.dsl_keywords.register(:subtotal, subtotal_keyword)
|
|
29
|
+
kernel.dsl_keywords.register(:tax_amount, tax_amount_keyword)
|
|
30
|
+
kernel.dsl_keywords.register(:grand_total, grand_total_keyword)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def order_items_keyword
|
|
34
|
+
Igniter::Contracts::DslKeyword.new(:order_items) do |name = :items, from:, builder:, key: :items|
|
|
35
|
+
builder.profile.dsl_keyword(:lookup).call(
|
|
36
|
+
name,
|
|
37
|
+
from: from.to_sym,
|
|
38
|
+
key: key.to_sym,
|
|
39
|
+
builder: builder
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def subtotal_keyword
|
|
45
|
+
Igniter::Contracts::DslKeyword.new(:subtotal) do |name = :subtotal, from:, builder:, amount_key: :amount|
|
|
46
|
+
builder.profile.dsl_keyword(:sum).call(
|
|
47
|
+
name,
|
|
48
|
+
from: from.to_sym,
|
|
49
|
+
using: amount_key.to_sym,
|
|
50
|
+
builder: builder
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def tax_amount_keyword
|
|
56
|
+
Igniter::Contracts::DslKeyword.new(:tax_amount) do |name = :tax, amount:, rate:, builder:|
|
|
57
|
+
dependencies = [amount.to_sym, rate.to_sym]
|
|
58
|
+
builder.add_operation(
|
|
59
|
+
kind: :compute,
|
|
60
|
+
name: name,
|
|
61
|
+
depends_on: dependencies,
|
|
62
|
+
callable: lambda do |**values|
|
|
63
|
+
values.fetch(amount.to_sym) * values.fetch(rate.to_sym)
|
|
64
|
+
end
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def grand_total_keyword
|
|
70
|
+
Igniter::Contracts::DslKeyword.new(:grand_total) do |name = :grand_total, subtotal:, builder:, tax: nil, shipping: nil, discount: nil|
|
|
71
|
+
dependency_names = [subtotal, tax, shipping, discount].compact.map(&:to_sym)
|
|
72
|
+
|
|
73
|
+
builder.add_operation(
|
|
74
|
+
kind: :compute,
|
|
75
|
+
name: name,
|
|
76
|
+
depends_on: dependency_names,
|
|
77
|
+
callable: lambda do |**values|
|
|
78
|
+
total = values.fetch(subtotal.to_sym)
|
|
79
|
+
total += values.fetch(tax.to_sym) if tax
|
|
80
|
+
total += values.fetch(shipping.to_sym) if shipping
|
|
81
|
+
total -= values.fetch(discount.to_sym) if discount
|
|
82
|
+
total
|
|
83
|
+
end
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Extensions
|
|
5
|
+
module Contracts
|
|
6
|
+
module ComposePack
|
|
7
|
+
Invocation = Struct.new(:operation, :compiled_graph, :inputs, :profile, keyword_init: true) do
|
|
8
|
+
def initialize(operation:, compiled_graph:, inputs:, profile:)
|
|
9
|
+
super(
|
|
10
|
+
operation: operation,
|
|
11
|
+
compiled_graph: compiled_graph,
|
|
12
|
+
inputs: inputs,
|
|
13
|
+
profile: profile
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module LocalInvoker
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def call(invocation:)
|
|
22
|
+
Igniter::Contracts.execute(
|
|
23
|
+
invocation.compiled_graph,
|
|
24
|
+
inputs: invocation.inputs,
|
|
25
|
+
profile: invocation.profile
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
def manifest
|
|
32
|
+
Igniter::Contracts::PackManifest.new(
|
|
33
|
+
name: :extensions_compose,
|
|
34
|
+
node_contracts: [Igniter::Contracts::PackManifest.node(:compose)],
|
|
35
|
+
registry_contracts: [
|
|
36
|
+
Igniter::Contracts::PackManifest.validator(:compose_dependencies),
|
|
37
|
+
Igniter::Contracts::PackManifest.validator(:compose_contracts),
|
|
38
|
+
Igniter::Contracts::PackManifest.validator(:compose_invokers)
|
|
39
|
+
],
|
|
40
|
+
metadata: { category: :orchestration },
|
|
41
|
+
provides_capabilities: %i[subgraph_invocation nested_contracts]
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def install_into(kernel)
|
|
46
|
+
kernel.nodes.register(:compose,
|
|
47
|
+
Igniter::Contracts::NodeType.new(kind: :compose,
|
|
48
|
+
metadata: { category: :orchestration }))
|
|
49
|
+
kernel.dsl_keywords.register(:compose, compose_keyword)
|
|
50
|
+
kernel.validators.register(:compose_dependencies, method(:validate_compose_dependencies))
|
|
51
|
+
kernel.validators.register(:compose_contracts, method(:validate_compose_contracts))
|
|
52
|
+
kernel.validators.register(:compose_invokers, method(:validate_compose_invokers))
|
|
53
|
+
kernel.runtime_handlers.register(:compose, method(:handle_compose))
|
|
54
|
+
kernel
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def compose_keyword
|
|
58
|
+
Igniter::Contracts::DslKeyword.new(:compose) do |name, builder:, contract: nil, inputs: {}, output: nil, via: nil, &block|
|
|
59
|
+
compiled_graph = compile_contract(name: name, contract: contract, profile: builder.profile, block: block)
|
|
60
|
+
input_map = normalize_inputs(inputs)
|
|
61
|
+
|
|
62
|
+
builder.add_operation(
|
|
63
|
+
kind: :compose,
|
|
64
|
+
name: name,
|
|
65
|
+
depends_on: extract_dependencies(input_map),
|
|
66
|
+
inputs: input_map,
|
|
67
|
+
compiled_graph: compiled_graph,
|
|
68
|
+
output_name: output&.to_sym,
|
|
69
|
+
invoker: via
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def validate_compose_dependencies(operations:, profile: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
75
|
+
available = operations.reject(&:output?).map(&:name)
|
|
76
|
+
missing = operations.select { |operation| operation.kind == :compose }
|
|
77
|
+
.flat_map { |operation| Array(operation.attributes[:depends_on]) }
|
|
78
|
+
.map(&:to_sym)
|
|
79
|
+
.reject { |name| available.include?(name) }
|
|
80
|
+
.uniq
|
|
81
|
+
return [] if missing.empty?
|
|
82
|
+
|
|
83
|
+
[Igniter::Contracts::ValidationFinding.new(
|
|
84
|
+
code: :missing_compose_dependencies,
|
|
85
|
+
message: "compose dependencies are not defined: #{missing.map(&:to_s).join(", ")}",
|
|
86
|
+
subjects: missing
|
|
87
|
+
)]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def validate_compose_contracts(operations:, profile:)
|
|
91
|
+
compose_operations = operations.select { |operation| operation.kind == :compose }
|
|
92
|
+
findings = []
|
|
93
|
+
|
|
94
|
+
invalid_contracts = compose_operations.reject do |operation|
|
|
95
|
+
operation.attributes[:compiled_graph].is_a?(Igniter::Contracts::CompiledGraph)
|
|
96
|
+
end
|
|
97
|
+
if invalid_contracts.any?
|
|
98
|
+
findings << Igniter::Contracts::ValidationFinding.new(
|
|
99
|
+
code: :invalid_compose_contract,
|
|
100
|
+
message: "compose nodes require a compiled contract graph: #{invalid_contracts.map(&:name).join(", ")}",
|
|
101
|
+
subjects: invalid_contracts.map(&:name)
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
mismatched = compose_operations.select do |operation|
|
|
106
|
+
compiled_graph = operation.attributes[:compiled_graph]
|
|
107
|
+
compiled_graph.is_a?(Igniter::Contracts::CompiledGraph) &&
|
|
108
|
+
compiled_graph.profile_fingerprint != profile.fingerprint
|
|
109
|
+
end
|
|
110
|
+
if mismatched.any?
|
|
111
|
+
findings << Igniter::Contracts::ValidationFinding.new(
|
|
112
|
+
code: :compose_profile_mismatch,
|
|
113
|
+
message: "compose contracts were compiled against a different profile: #{mismatched.map(&:name).join(", ")}",
|
|
114
|
+
subjects: mismatched.map(&:name)
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
missing_outputs = compose_operations.filter_map do |operation|
|
|
119
|
+
output_name = operation.attributes[:output_name]
|
|
120
|
+
next if output_name.nil?
|
|
121
|
+
|
|
122
|
+
compiled_graph = operation.attributes[:compiled_graph]
|
|
123
|
+
next if compiled_graph.is_a?(Igniter::Contracts::CompiledGraph) && compose_output_names(compiled_graph).include?(output_name)
|
|
124
|
+
|
|
125
|
+
operation.name
|
|
126
|
+
end
|
|
127
|
+
if missing_outputs.any?
|
|
128
|
+
findings << Igniter::Contracts::ValidationFinding.new(
|
|
129
|
+
code: :unknown_compose_output,
|
|
130
|
+
message: "compose output selections are not defined in the nested contract: #{missing_outputs.map(&:to_s).join(", ")}",
|
|
131
|
+
subjects: missing_outputs
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
findings
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_compose_invokers(operations:, profile: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
139
|
+
invalid = operations.select { |operation| operation.kind == :compose }
|
|
140
|
+
.reject do |operation|
|
|
141
|
+
invoker = operation.attributes[:invoker]
|
|
142
|
+
invoker.nil? || invoker.respond_to?(:call)
|
|
143
|
+
end
|
|
144
|
+
return [] if invalid.empty?
|
|
145
|
+
|
|
146
|
+
[Igniter::Contracts::ValidationFinding.new(
|
|
147
|
+
code: :invalid_compose_invoker,
|
|
148
|
+
message: "compose via: must be callable: #{invalid.map(&:name).join(", ")}",
|
|
149
|
+
subjects: invalid.map(&:name)
|
|
150
|
+
)]
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def handle_compose(operation:, state:, profile:, **)
|
|
154
|
+
nested_inputs = resolve_inputs(operation, state: state)
|
|
155
|
+
invocation = Invocation.new(
|
|
156
|
+
operation: operation,
|
|
157
|
+
compiled_graph: operation.attributes.fetch(:compiled_graph),
|
|
158
|
+
inputs: nested_inputs,
|
|
159
|
+
profile: profile
|
|
160
|
+
)
|
|
161
|
+
invoker = operation.attributes[:invoker] || LocalInvoker
|
|
162
|
+
result = invoker.call(invocation: invocation)
|
|
163
|
+
unless result.is_a?(Igniter::Contracts::ExecutionResult)
|
|
164
|
+
raise Igniter::Contracts::Error,
|
|
165
|
+
"compose invoker for #{operation.name} must return an ExecutionResult"
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
output_name = operation.attributes[:output_name]
|
|
169
|
+
return result if output_name.nil?
|
|
170
|
+
|
|
171
|
+
result.output(output_name)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
private
|
|
175
|
+
|
|
176
|
+
def compile_contract(name:, contract:, profile:, block:)
|
|
177
|
+
raise ArgumentError, "compose :#{name} accepts either contract: or a block, not both" if contract && block
|
|
178
|
+
|
|
179
|
+
source = contract || block
|
|
180
|
+
raise ArgumentError, "compose :#{name} requires contract: or a block" unless source
|
|
181
|
+
|
|
182
|
+
return source if source.is_a?(Igniter::Contracts::CompiledGraph)
|
|
183
|
+
return Igniter::Contracts.compile(profile: profile, &source) if source.respond_to?(:call)
|
|
184
|
+
|
|
185
|
+
raise ArgumentError, "compose :#{name} contract must be a compiled graph or callable"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def normalize_inputs(inputs)
|
|
189
|
+
raise ArgumentError, "compose inputs: must be a Hash" unless inputs.is_a?(Hash)
|
|
190
|
+
|
|
191
|
+
inputs.each_with_object({}) do |(key, value), memo|
|
|
192
|
+
memo[key.to_sym] = value
|
|
193
|
+
end.freeze
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def extract_dependencies(input_map)
|
|
197
|
+
input_map.values.grep(Symbol).map(&:to_sym).uniq
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def resolve_inputs(operation, state:)
|
|
201
|
+
operation.attributes.fetch(:inputs).each_with_object({}) do |(key, source), memo|
|
|
202
|
+
memo[key.to_sym] = source.is_a?(Symbol) ? state.fetch(source.to_sym) : source
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def compose_output_names(compiled_graph)
|
|
207
|
+
compiled_graph.operations.select(&:output?).map(&:name)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Extensions
|
|
5
|
+
module Contracts
|
|
6
|
+
module ContentAddressing
|
|
7
|
+
class Cache
|
|
8
|
+
def initialize
|
|
9
|
+
@store = {}
|
|
10
|
+
@hits = 0
|
|
11
|
+
@misses = 0
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fetch(key)
|
|
16
|
+
@mutex.synchronize do
|
|
17
|
+
entry = @store[key.hex]
|
|
18
|
+
if entry.nil?
|
|
19
|
+
@misses += 1
|
|
20
|
+
nil
|
|
21
|
+
else
|
|
22
|
+
@hits += 1
|
|
23
|
+
entry
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def store(key, value)
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
@store[key.hex] = value
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def clear
|
|
35
|
+
@mutex.synchronize do
|
|
36
|
+
@store.clear
|
|
37
|
+
@hits = 0
|
|
38
|
+
@misses = 0
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def size
|
|
43
|
+
@mutex.synchronize { @store.size }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def stats
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
{
|
|
49
|
+
size: @store.size,
|
|
50
|
+
hits: @hits,
|
|
51
|
+
misses: @misses
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Extensions
|
|
7
|
+
module Contracts
|
|
8
|
+
module ContentAddressing
|
|
9
|
+
class ContentKey
|
|
10
|
+
attr_reader :hex
|
|
11
|
+
|
|
12
|
+
def initialize(hex)
|
|
13
|
+
@hex = hex.freeze
|
|
14
|
+
freeze
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_s
|
|
18
|
+
"ca:#{hex}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def ==(other)
|
|
22
|
+
other.is_a?(self.class) && other.hex == hex
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
alias eql? ==
|
|
26
|
+
|
|
27
|
+
def hash
|
|
28
|
+
hex.hash
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def compute(fingerprint:, inputs:)
|
|
33
|
+
payload = stable_serialize(inputs)
|
|
34
|
+
new(Digest::SHA256.hexdigest("#{fingerprint}\x00#{payload}")[0..23])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def stable_serialize(value)
|
|
40
|
+
case value
|
|
41
|
+
when Hash
|
|
42
|
+
pairs = value.sort_by { |key, _entry| key.to_s }.map do |key, entry|
|
|
43
|
+
"#{key}:#{stable_serialize(entry)}"
|
|
44
|
+
end
|
|
45
|
+
"{#{pairs.join(",")}}"
|
|
46
|
+
when Array
|
|
47
|
+
"[#{value.map { |entry| stable_serialize(entry) }.join(",")}]"
|
|
48
|
+
when String
|
|
49
|
+
value.inspect
|
|
50
|
+
when Symbol
|
|
51
|
+
":#{value}"
|
|
52
|
+
when Numeric, NilClass, TrueClass, FalseClass
|
|
53
|
+
value.inspect
|
|
54
|
+
else
|
|
55
|
+
value.respond_to?(:to_h) ? stable_serialize(value.to_h) : value.hash.to_s
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module Extensions
|
|
5
|
+
module Contracts
|
|
6
|
+
module ContentAddressing
|
|
7
|
+
class Declaration
|
|
8
|
+
attr_reader :callable, :fingerprint, :cache, :capabilities
|
|
9
|
+
|
|
10
|
+
def initialize(callable:, fingerprint:, cache:, capabilities:)
|
|
11
|
+
@callable = callable
|
|
12
|
+
@fingerprint = fingerprint.to_s.freeze
|
|
13
|
+
@cache = cache
|
|
14
|
+
@capabilities = Array(capabilities).map(&:to_sym).uniq.freeze
|
|
15
|
+
freeze
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(**kwargs)
|
|
19
|
+
key = content_key(kwargs)
|
|
20
|
+
cached = cache.fetch(key)
|
|
21
|
+
return cached unless cached.nil?
|
|
22
|
+
|
|
23
|
+
value = callable.call(**kwargs)
|
|
24
|
+
cache.store(key, value)
|
|
25
|
+
value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def declared_capabilities
|
|
29
|
+
capabilities
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def pure?
|
|
33
|
+
capabilities.include?(:pure)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def content_fingerprint
|
|
37
|
+
fingerprint
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def content_key(inputs)
|
|
41
|
+
ContentKey.compute(fingerprint: fingerprint, inputs: inputs)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|