igniter 0.4.0 → 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/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/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/validators/callable_validator.rb +21 -3
- 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 +32 -0
- data/lib/igniter/effect.rb +91 -0
- data/lib/igniter/effect_registry.rb +78 -0
- data/lib/igniter/errors.rb +11 -1
- 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/invariant.rb +50 -0
- data/lib/igniter/model/effect_node.rb +37 -0
- data/lib/igniter/model.rb +1 -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/resolver.rb +15 -0
- 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/stream_loop.rb +80 -0
- data/lib/igniter/supervisor.rb +167 -0
- data/lib/igniter/version.rb +1 -1
- data/lib/igniter.rb +10 -0
- metadata +57 -1
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "differential/divergence"
|
|
5
|
+
require_relative "differential/report"
|
|
6
|
+
require_relative "differential/formatter"
|
|
7
|
+
require_relative "differential/runner"
|
|
8
|
+
|
|
9
|
+
module Igniter
|
|
10
|
+
# Differential Execution — compare two contract implementations output-by-output.
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
#
|
|
14
|
+
# require "igniter/extensions/differential"
|
|
15
|
+
#
|
|
16
|
+
# # Standalone comparison
|
|
17
|
+
# report = Igniter::Differential.compare(
|
|
18
|
+
# primary: PricingV1,
|
|
19
|
+
# candidate: PricingV2,
|
|
20
|
+
# inputs: { price: 50.0, quantity: 3 }
|
|
21
|
+
# )
|
|
22
|
+
# puts report.explain
|
|
23
|
+
# puts report.match? # => false
|
|
24
|
+
# puts report.divergences # => [#<Divergence ...>]
|
|
25
|
+
#
|
|
26
|
+
# # Per-instance diff (primary already resolved)
|
|
27
|
+
# contract = PricingV1.new(price: 50.0, quantity: 3)
|
|
28
|
+
# contract.resolve_all
|
|
29
|
+
# report = contract.diff_against(PricingV2)
|
|
30
|
+
#
|
|
31
|
+
# # Shadow mode (runs candidate alongside primary automatically)
|
|
32
|
+
# class PricingContract < Igniter::Contract
|
|
33
|
+
# shadow_with PricingV2, on_divergence: ->(r) { Rails.logger.warn(r.summary) }
|
|
34
|
+
# define { ... }
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
module Differential
|
|
38
|
+
class DifferentialError < Igniter::Error; end
|
|
39
|
+
|
|
40
|
+
# Compare +primary+ and +candidate+ contract classes on the given +inputs+.
|
|
41
|
+
#
|
|
42
|
+
# @param primary [Class<Igniter::Contract>]
|
|
43
|
+
# @param candidate [Class<Igniter::Contract>]
|
|
44
|
+
# @param inputs [Hash]
|
|
45
|
+
# @param tolerance [Numeric, nil] optional allowable numeric difference
|
|
46
|
+
# @return [Report]
|
|
47
|
+
def self.compare(primary:, candidate:, inputs:, tolerance: nil)
|
|
48
|
+
Runner.new(primary, candidate, tolerance: tolerance).run(inputs)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -248,6 +248,21 @@ module Igniter
|
|
|
248
248
|
)
|
|
249
249
|
end
|
|
250
250
|
|
|
251
|
+
def effect(name, uses:, depends_on: nil, with: nil, **metadata)
|
|
252
|
+
adapter_class = resolve_effect_adapter(name, uses)
|
|
253
|
+
|
|
254
|
+
add_node(
|
|
255
|
+
Model::EffectNode.new(
|
|
256
|
+
id: next_id,
|
|
257
|
+
name: name,
|
|
258
|
+
dependencies: normalize_dependencies(depends_on: depends_on, with: with),
|
|
259
|
+
adapter_class: adapter_class,
|
|
260
|
+
path: scoped_path(name),
|
|
261
|
+
metadata: with_source_location(metadata)
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
end
|
|
265
|
+
|
|
251
266
|
def await(name, event:, **metadata)
|
|
252
267
|
add_node(
|
|
253
268
|
Model::AwaitNode.new(
|
|
@@ -306,6 +321,23 @@ module Igniter
|
|
|
306
321
|
Array(dependencies)
|
|
307
322
|
end
|
|
308
323
|
|
|
324
|
+
def resolve_effect_adapter(name, uses)
|
|
325
|
+
case uses
|
|
326
|
+
when Symbol, String
|
|
327
|
+
Igniter.effect_registry.fetch(uses.to_sym).adapter_class
|
|
328
|
+
when Class
|
|
329
|
+
unless uses <= Igniter::Effect
|
|
330
|
+
raise CompileError,
|
|
331
|
+
"effect :#{name} `uses:` must be an Igniter::Effect subclass or a registered effect name"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
uses
|
|
335
|
+
else
|
|
336
|
+
raise CompileError,
|
|
337
|
+
"effect :#{name} `uses:` must be an Igniter::Effect subclass or a registered effect name"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
309
341
|
def build_guard_matcher(matcher_name, matcher_value, dependency)
|
|
310
342
|
case matcher_name
|
|
311
343
|
when :eq
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
# Base class for side-effect adapters.
|
|
5
|
+
#
|
|
6
|
+
# An Effect is a first-class node in the computation graph that encapsulates
|
|
7
|
+
# an external interaction — database, HTTP call, cache write, queue publish, etc.
|
|
8
|
+
#
|
|
9
|
+
# Effects are declared in contracts via the `effect` DSL keyword:
|
|
10
|
+
#
|
|
11
|
+
# class UserRepository < Igniter::Effect
|
|
12
|
+
# effect_type :database
|
|
13
|
+
# idempotent false
|
|
14
|
+
#
|
|
15
|
+
# def call(user_id:)
|
|
16
|
+
# { id: user_id, name: DB.find(user_id) }
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# compensate do |inputs:, value:|
|
|
20
|
+
# DB.delete(value[:id])
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# class MyContract < Igniter::Contract
|
|
25
|
+
# define do
|
|
26
|
+
# input :user_id
|
|
27
|
+
# effect :user_data, uses: UserRepository, depends_on: :user_id
|
|
28
|
+
# output :user_data
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# Effects participate fully in the graph:
|
|
33
|
+
# - Dependency resolution and topological ordering
|
|
34
|
+
# - Execution reports (shown as `effect:database`)
|
|
35
|
+
# - Saga compensations (built-in or contract-level)
|
|
36
|
+
# - Provenance tracing
|
|
37
|
+
class Effect < Executor
|
|
38
|
+
class << self
|
|
39
|
+
def inherited(subclass)
|
|
40
|
+
super
|
|
41
|
+
subclass.instance_variable_set(:@effect_type, @effect_type)
|
|
42
|
+
subclass.instance_variable_set(:@idempotent, @idempotent || false)
|
|
43
|
+
subclass.instance_variable_set(:@_built_in_compensation, @_built_in_compensation)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Declares the category of side effect (e.g., :database, :http, :cache).
|
|
47
|
+
# Shown in execution reports as `effect:<type>`.
|
|
48
|
+
#
|
|
49
|
+
# @param value [Symbol, nil] — omit to read current value
|
|
50
|
+
# @return [Symbol]
|
|
51
|
+
def effect_type(value = nil)
|
|
52
|
+
return @effect_type || :generic if value.nil?
|
|
53
|
+
|
|
54
|
+
@effect_type = value.to_sym
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Marks this effect as idempotent — safe to retry without side effects.
|
|
58
|
+
# Informational metadata; does not change execution behaviour.
|
|
59
|
+
#
|
|
60
|
+
# @param value [Boolean]
|
|
61
|
+
def idempotent(value = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
62
|
+
@idempotent = value
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Boolean]
|
|
66
|
+
def idempotent?
|
|
67
|
+
@idempotent || false
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Declares a built-in compensating action for this effect.
|
|
71
|
+
#
|
|
72
|
+
# Called automatically during a Saga rollback when this effect succeeded
|
|
73
|
+
# but a downstream node failed. A contract-level `compensate :node_name`
|
|
74
|
+
# block takes precedence over the built-in one.
|
|
75
|
+
#
|
|
76
|
+
# The block receives:
|
|
77
|
+
# inputs: — Hash of the node's dependency values when it ran
|
|
78
|
+
# value: — the value produced by this effect (now being undone)
|
|
79
|
+
def compensate(&block)
|
|
80
|
+
raise ArgumentError, "Effect.compensate requires a block" unless block
|
|
81
|
+
|
|
82
|
+
@_built_in_compensation = block
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Proc, nil]
|
|
86
|
+
def built_in_compensation
|
|
87
|
+
@_built_in_compensation
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
# Registry for named effect adapters.
|
|
5
|
+
#
|
|
6
|
+
# Allows registering effects by symbolic keys and resolving them in the DSL:
|
|
7
|
+
#
|
|
8
|
+
# Igniter.register_effect(:users_db, UserRepository)
|
|
9
|
+
#
|
|
10
|
+
# # In a contract:
|
|
11
|
+
# effect :user_data, uses: :users_db, depends_on: :user_id
|
|
12
|
+
#
|
|
13
|
+
# This decouples contracts from concrete adapter classes and enables
|
|
14
|
+
# environment-specific swaps (e.g. mock adapters in tests):
|
|
15
|
+
#
|
|
16
|
+
# # In spec_helper.rb:
|
|
17
|
+
# Igniter.effect_registry.clear
|
|
18
|
+
# Igniter.register_effect(:users_db, FakeUserRepository)
|
|
19
|
+
class EffectRegistry
|
|
20
|
+
Registration = Struct.new(:key, :adapter_class, :metadata, keyword_init: true)
|
|
21
|
+
|
|
22
|
+
def initialize
|
|
23
|
+
@entries = {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Register an effect adapter under a symbolic key.
|
|
27
|
+
#
|
|
28
|
+
# @param key [Symbol, String]
|
|
29
|
+
# @param adapter_class [Class] must be a subclass of Igniter::Effect
|
|
30
|
+
# @param metadata [Hash] optional arbitrary metadata
|
|
31
|
+
# @return [self]
|
|
32
|
+
def register(key, adapter_class, **metadata)
|
|
33
|
+
key = key.to_sym
|
|
34
|
+
unless adapter_class.is_a?(Class) && adapter_class <= Igniter::Effect
|
|
35
|
+
raise ArgumentError, "#{adapter_class.inspect} must be a subclass of Igniter::Effect"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
@entries[key] = Registration.new(key: key, adapter_class: adapter_class, metadata: metadata.freeze)
|
|
39
|
+
self
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Fetch a registration by key.
|
|
43
|
+
#
|
|
44
|
+
# @param key [Symbol, String]
|
|
45
|
+
# @return [Registration]
|
|
46
|
+
# @raise [KeyError] if not registered
|
|
47
|
+
def fetch(key)
|
|
48
|
+
@entries.fetch(key.to_sym) do
|
|
49
|
+
raise KeyError,
|
|
50
|
+
"Effect '#{key}' is not registered. " \
|
|
51
|
+
"Use Igniter.register_effect(:#{key}, AdapterClass) before compiling."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param key [Symbol, String]
|
|
56
|
+
# @return [Boolean]
|
|
57
|
+
def registered?(key)
|
|
58
|
+
@entries.key?(key.to_sym)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# @return [Array<Registration>]
|
|
62
|
+
def all
|
|
63
|
+
@entries.values.freeze
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [Integer]
|
|
67
|
+
def size
|
|
68
|
+
@entries.size
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Remove all registrations. Useful in tests.
|
|
72
|
+
# @return [self]
|
|
73
|
+
def clear
|
|
74
|
+
@entries.clear
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
data/lib/igniter/errors.rb
CHANGED
|
@@ -45,7 +45,7 @@ module Igniter
|
|
|
45
45
|
|
|
46
46
|
return message if details.empty?
|
|
47
47
|
|
|
48
|
-
"#{message} [#{details.join(
|
|
48
|
+
"#{message} [#{details.join(", ")}]"
|
|
49
49
|
end
|
|
50
50
|
end
|
|
51
51
|
|
|
@@ -58,6 +58,7 @@ module Igniter
|
|
|
58
58
|
class ResolutionError < Error; end
|
|
59
59
|
class CompositionError < Error; end
|
|
60
60
|
class BranchSelectionError < Error; end
|
|
61
|
+
|
|
61
62
|
class PendingDependencyError < Error
|
|
62
63
|
attr_reader :deferred_result
|
|
63
64
|
|
|
@@ -66,4 +67,13 @@ module Igniter
|
|
|
66
67
|
super(message, context: context)
|
|
67
68
|
end
|
|
68
69
|
end
|
|
70
|
+
|
|
71
|
+
class InvariantError < Error
|
|
72
|
+
attr_reader :violations
|
|
73
|
+
|
|
74
|
+
def initialize(message = nil, violations: [], context: {})
|
|
75
|
+
@violations = violations.freeze
|
|
76
|
+
super(message, context: context)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
69
79
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module ExecutionReport
|
|
5
|
+
# Builds an ExecutionReport::Report by reading the compiled graph's
|
|
6
|
+
# resolution order and matching each node against the execution cache.
|
|
7
|
+
#
|
|
8
|
+
# Works for both successful and failed executions — nodes that never
|
|
9
|
+
# ran (because a dependency failed) appear with status :pending.
|
|
10
|
+
class Builder
|
|
11
|
+
class << self
|
|
12
|
+
def build(contract)
|
|
13
|
+
new(contract).build
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(contract)
|
|
18
|
+
@contract = contract
|
|
19
|
+
@graph = contract.execution.compiled_graph
|
|
20
|
+
@cache = contract.execution.cache
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build
|
|
24
|
+
entries = @graph.resolution_order.map { |node| entry_for(node) }
|
|
25
|
+
Report.new(contract_class: @contract.class, entries: entries)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def entry_for(node) # rubocop:disable Metrics/MethodLength
|
|
31
|
+
state = @cache.fetch(node.name)
|
|
32
|
+
|
|
33
|
+
status = if state.nil?
|
|
34
|
+
:pending
|
|
35
|
+
elsif state.succeeded?
|
|
36
|
+
:succeeded
|
|
37
|
+
elsif state.failed?
|
|
38
|
+
:failed
|
|
39
|
+
else
|
|
40
|
+
:pending
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
NodeEntry.new(
|
|
44
|
+
name: node.name,
|
|
45
|
+
kind: node.kind,
|
|
46
|
+
status: status,
|
|
47
|
+
value: state&.value,
|
|
48
|
+
error: state&.error,
|
|
49
|
+
effect_type: node.respond_to?(:effect_type) ? node.effect_type : nil
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module ExecutionReport
|
|
5
|
+
# Formats an ExecutionReport::Report as human-readable text.
|
|
6
|
+
#
|
|
7
|
+
# Example:
|
|
8
|
+
#
|
|
9
|
+
# Contract: OrderWorkflow
|
|
10
|
+
# Success: NO
|
|
11
|
+
#
|
|
12
|
+
# [ok] input :order_id
|
|
13
|
+
# [ok] input :amount
|
|
14
|
+
# [ok] compute :reserve_stock
|
|
15
|
+
# [fail] compute :charge_card
|
|
16
|
+
# error: Insufficient funds
|
|
17
|
+
# [pend] compute :send_confirmation
|
|
18
|
+
#
|
|
19
|
+
module Formatter
|
|
20
|
+
class << self
|
|
21
|
+
def format(report)
|
|
22
|
+
lines = []
|
|
23
|
+
lines << "Contract: #{report.contract_class.name}"
|
|
24
|
+
lines << "Success: #{report.success? ? "YES" : "NO"}"
|
|
25
|
+
lines << ""
|
|
26
|
+
|
|
27
|
+
report.entries.each do |entry|
|
|
28
|
+
append_entry(entry, lines)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
lines.join("\n")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def append_entry(entry, lines)
|
|
37
|
+
tag = case entry.status
|
|
38
|
+
when :succeeded then "[ok] "
|
|
39
|
+
when :failed then "[fail]"
|
|
40
|
+
else "[pend]"
|
|
41
|
+
end
|
|
42
|
+
kind_label = entry.effect_type ? "effect:#{entry.effect_type}" : entry.kind.to_s
|
|
43
|
+
kind_str = kind_label.ljust(10)
|
|
44
|
+
lines << " #{tag} #{kind_str} :#{entry.name}"
|
|
45
|
+
lines << " error: #{entry.error.message}" if entry.failed? && entry.error
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module ExecutionReport
|
|
5
|
+
# Snapshot of a single node's execution state.
|
|
6
|
+
class NodeEntry
|
|
7
|
+
attr_reader :name, :kind, :status, :value, :error, :effect_type
|
|
8
|
+
|
|
9
|
+
def initialize(name:, kind:, status:, value: nil, error: nil, effect_type: nil) # rubocop:disable Metrics/ParameterLists
|
|
10
|
+
@name = name
|
|
11
|
+
@kind = kind
|
|
12
|
+
@status = status
|
|
13
|
+
@value = value
|
|
14
|
+
@error = error
|
|
15
|
+
@effect_type = effect_type
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def succeeded? = status == :succeeded
|
|
20
|
+
def failed? = status == :failed
|
|
21
|
+
def pending? = !succeeded? && !failed?
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Igniter
|
|
4
|
+
module ExecutionReport
|
|
5
|
+
# Structured post-hoc report of what happened during contract execution.
|
|
6
|
+
#
|
|
7
|
+
# Built from the compiled graph's resolution order + execution cache state.
|
|
8
|
+
# Can be generated any time after resolve_all (including after an error).
|
|
9
|
+
#
|
|
10
|
+
# Attributes:
|
|
11
|
+
# contract_class — the contract class that was executed
|
|
12
|
+
# entries — Array<NodeEntry> in resolution order
|
|
13
|
+
class Report
|
|
14
|
+
attr_reader :contract_class, :entries
|
|
15
|
+
|
|
16
|
+
def initialize(contract_class:, entries:)
|
|
17
|
+
@contract_class = contract_class
|
|
18
|
+
@entries = entries.freeze
|
|
19
|
+
freeze
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# True when no nodes failed.
|
|
23
|
+
def success?
|
|
24
|
+
entries.none?(&:failed?)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Symbol names of nodes that succeeded.
|
|
28
|
+
def resolved_nodes
|
|
29
|
+
entries.select(&:succeeded?).map(&:name)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Symbol names of nodes that failed.
|
|
33
|
+
def failed_nodes
|
|
34
|
+
entries.select(&:failed?).map(&:name)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Symbol names of nodes that never ran.
|
|
38
|
+
def pending_nodes
|
|
39
|
+
entries.select(&:pending?).map(&:name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Map of { node_name => error } for failed nodes.
|
|
43
|
+
def errors
|
|
44
|
+
entries.select(&:failed?).each_with_object({}) { |e, h| h[e.name] = e.error }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Human-readable execution report.
|
|
48
|
+
def explain
|
|
49
|
+
Formatter.format(self)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
alias to_s explain
|
|
53
|
+
|
|
54
|
+
def to_h
|
|
55
|
+
{
|
|
56
|
+
contract: contract_class.name,
|
|
57
|
+
success: success?,
|
|
58
|
+
nodes: entries.map do |e|
|
|
59
|
+
{ name: e.name, kind: e.kind, status: e.status, error: e.error&.message }
|
|
60
|
+
end
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "execution_report/node_entry"
|
|
5
|
+
require_relative "execution_report/formatter"
|
|
6
|
+
require_relative "execution_report/report"
|
|
7
|
+
require_relative "execution_report/builder"
|
|
8
|
+
|
|
9
|
+
module Igniter
|
|
10
|
+
# Post-hoc execution report — answers "what ran, what succeeded, what failed?"
|
|
11
|
+
#
|
|
12
|
+
# Reconstructs a structured timeline from the compiled graph's resolution
|
|
13
|
+
# order and the execution cache. Works regardless of whether the contract
|
|
14
|
+
# succeeded or raised an error.
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
#
|
|
18
|
+
# require "igniter/extensions/execution_report"
|
|
19
|
+
#
|
|
20
|
+
# contract = MyContract.new(inputs)
|
|
21
|
+
# contract.resolve_all rescue nil # run regardless of outcome
|
|
22
|
+
#
|
|
23
|
+
# report = contract.execution_report
|
|
24
|
+
# report.success? # => false
|
|
25
|
+
# report.failed_nodes # => [:charge_card]
|
|
26
|
+
# report.pending_nodes # => [:send_confirmation]
|
|
27
|
+
# puts report.explain # formatted table
|
|
28
|
+
#
|
|
29
|
+
module ExecutionReport
|
|
30
|
+
class ExecutionReportError < Igniter::Error; end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require "igniter/differential"
|
|
5
|
+
|
|
6
|
+
module Igniter
|
|
7
|
+
module Extensions
|
|
8
|
+
# Patches Igniter::Contract with:
|
|
9
|
+
# - Class method: shadow_with(candidate, async:, on_divergence:, tolerance:)
|
|
10
|
+
# - Instance method: diff_against(candidate_class, tolerance:)
|
|
11
|
+
# - Automatic shadow execution after resolve_all (when shadow_with is declared)
|
|
12
|
+
#
|
|
13
|
+
# This module is applied globally via:
|
|
14
|
+
# Igniter::Contract.include(Igniter::Extensions::Differential)
|
|
15
|
+
#
|
|
16
|
+
module Differential
|
|
17
|
+
def self.included(base)
|
|
18
|
+
base.extend(ClassMethods)
|
|
19
|
+
base.prepend(InstanceMethods)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# ── Class-level DSL ────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
module ClassMethods
|
|
25
|
+
# Declare a shadow candidate that runs alongside every resolve_all call.
|
|
26
|
+
#
|
|
27
|
+
# @param candidate_class [Class<Igniter::Contract>]
|
|
28
|
+
# @param async [Boolean] run the shadow in a background Thread
|
|
29
|
+
# @param on_divergence [#call, nil] invoked with a Report when outputs differ
|
|
30
|
+
# @param tolerance [Numeric, nil] passed to the differential runner
|
|
31
|
+
def shadow_with(candidate_class, async: false, on_divergence: nil, tolerance: nil)
|
|
32
|
+
@_shadow_candidate = candidate_class
|
|
33
|
+
@_shadow_async = async
|
|
34
|
+
@_shadow_on_divergence = on_divergence
|
|
35
|
+
@_shadow_tolerance = tolerance
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def shadow_candidate = @_shadow_candidate
|
|
39
|
+
def shadow_async? = @_shadow_async || false
|
|
40
|
+
def shadow_on_divergence = @_shadow_on_divergence
|
|
41
|
+
def shadow_tolerance = @_shadow_tolerance
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# ── Instance override + new public method ─────────────────────────────
|
|
45
|
+
|
|
46
|
+
module InstanceMethods
|
|
47
|
+
# Intercepts resolve_all to trigger shadow execution when shadow_with
|
|
48
|
+
# has been declared. Uses a thread-local flag to prevent recursive
|
|
49
|
+
# shadow calls when the runner itself invokes resolve_all internally.
|
|
50
|
+
def resolve_all(...)
|
|
51
|
+
result = super
|
|
52
|
+
run_shadow unless Thread.current[:igniter_skip_shadow]
|
|
53
|
+
result
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Compare the already-executed primary contract against +candidate_class+.
|
|
57
|
+
# Avoids re-running the primary (reads outputs from the existing cache).
|
|
58
|
+
#
|
|
59
|
+
# Raises DifferentialError if resolve_all has not been called yet.
|
|
60
|
+
#
|
|
61
|
+
# @return [Igniter::Differential::Report]
|
|
62
|
+
def diff_against(candidate_class, tolerance: nil)
|
|
63
|
+
unless execution.cache.values.any?
|
|
64
|
+
raise Igniter::Differential::DifferentialError,
|
|
65
|
+
"Contract has not been executed — call resolve_all first"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
inputs = extract_inputs_from_execution
|
|
69
|
+
runner = Igniter::Differential::Runner.new(self.class, candidate_class, tolerance: tolerance)
|
|
70
|
+
runner.run_with_primary_execution(execution, inputs)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def run_shadow # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
76
|
+
candidate = self.class.shadow_candidate
|
|
77
|
+
return unless candidate
|
|
78
|
+
|
|
79
|
+
on_divergence = self.class.shadow_on_divergence
|
|
80
|
+
tolerance = self.class.shadow_tolerance
|
|
81
|
+
inputs = extract_inputs_from_execution
|
|
82
|
+
|
|
83
|
+
task = lambda do
|
|
84
|
+
runner = Igniter::Differential::Runner.new(self.class, candidate, tolerance: tolerance)
|
|
85
|
+
report = runner.run_with_primary_execution(execution, inputs)
|
|
86
|
+
on_divergence.call(report) if on_divergence && !report.match?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if self.class.shadow_async?
|
|
90
|
+
Thread.new { task.call }
|
|
91
|
+
else
|
|
92
|
+
task.call
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Reads input node values from the resolved cache for re-use in
|
|
97
|
+
# secondary executions (shadow / diff_against).
|
|
98
|
+
def extract_inputs_from_execution
|
|
99
|
+
graph = execution.compiled_graph
|
|
100
|
+
cache = execution.cache
|
|
101
|
+
|
|
102
|
+
graph.nodes.each_with_object({}) do |node, acc|
|
|
103
|
+
next unless node.kind == :input
|
|
104
|
+
|
|
105
|
+
state = cache.fetch(node.name)
|
|
106
|
+
acc[node.name] = state&.value
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
Igniter::Contract.include(Igniter::Extensions::Differential)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require "igniter/execution_report"
|
|
5
|
+
|
|
6
|
+
module Igniter
|
|
7
|
+
module Extensions
|
|
8
|
+
# Adds execution_report method to all Igniter contracts.
|
|
9
|
+
#
|
|
10
|
+
# Applied globally via:
|
|
11
|
+
# Igniter::Contract.include(Igniter::Extensions::ExecutionReport)
|
|
12
|
+
#
|
|
13
|
+
module ExecutionReport
|
|
14
|
+
# Build a structured execution report from the current execution state.
|
|
15
|
+
#
|
|
16
|
+
# Can be called after resolve_all succeeds OR after it raises — in both
|
|
17
|
+
# cases the cache contains partial or full execution state.
|
|
18
|
+
#
|
|
19
|
+
# @return [Igniter::ExecutionReport::Report]
|
|
20
|
+
def execution_report
|
|
21
|
+
Igniter::ExecutionReport::Builder.build(self)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Igniter::Contract.include(Igniter::Extensions::ExecutionReport)
|