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
data/lib/igniter/errors.rb
CHANGED
|
@@ -29,18 +29,23 @@ module Igniter
|
|
|
29
29
|
context[:source_location]
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
def execution_id
|
|
33
|
+
context[:execution_id]
|
|
34
|
+
end
|
|
35
|
+
|
|
32
36
|
private
|
|
33
37
|
|
|
34
|
-
def format_message(message, context)
|
|
38
|
+
def format_message(message, context) # rubocop:disable Metrics/AbcSize
|
|
35
39
|
details = []
|
|
36
40
|
details << "graph=#{context[:graph]}" if context[:graph]
|
|
37
41
|
details << "node=#{context[:node_name]}" if context[:node_name]
|
|
38
42
|
details << "path=#{context[:node_path]}" if context[:node_path]
|
|
43
|
+
details << "execution=#{context[:execution_id]}" if context[:execution_id]
|
|
39
44
|
details << "location=#{context[:source_location]}" if context[:source_location]
|
|
40
45
|
|
|
41
46
|
return message if details.empty?
|
|
42
47
|
|
|
43
|
-
"#{message} [#{details.join(
|
|
48
|
+
"#{message} [#{details.join(", ")}]"
|
|
44
49
|
end
|
|
45
50
|
end
|
|
46
51
|
|
|
@@ -53,6 +58,7 @@ module Igniter
|
|
|
53
58
|
class ResolutionError < Error; end
|
|
54
59
|
class CompositionError < Error; end
|
|
55
60
|
class BranchSelectionError < Error; end
|
|
61
|
+
|
|
56
62
|
class PendingDependencyError < Error
|
|
57
63
|
attr_reader :deferred_result
|
|
58
64
|
|
|
@@ -61,4 +67,13 @@ module Igniter
|
|
|
61
67
|
super(message, context: context)
|
|
62
68
|
end
|
|
63
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
|
|
64
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)
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require "igniter/invariant"
|
|
5
|
+
|
|
6
|
+
module Igniter
|
|
7
|
+
module Extensions
|
|
8
|
+
# Patches Igniter::Contract with:
|
|
9
|
+
# - Class method: invariant(name) { |output:, **| condition }
|
|
10
|
+
# - Instance method: check_invariants → Array<InvariantViolation>
|
|
11
|
+
# - Automatic post-execution check in resolve_all (raises InvariantError)
|
|
12
|
+
#
|
|
13
|
+
# Invariant blocks receive ONLY the contract's declared output values as
|
|
14
|
+
# keyword args — the stable public interface, independent of internal nodes.
|
|
15
|
+
# Use ** to absorb outputs you don't need.
|
|
16
|
+
#
|
|
17
|
+
# @example
|
|
18
|
+
# class PricingContract < Igniter::Contract
|
|
19
|
+
# define do
|
|
20
|
+
# input :price
|
|
21
|
+
# input :quantity
|
|
22
|
+
# compute :total, depends_on: %i[price quantity] do |price:, quantity:|
|
|
23
|
+
# price * quantity
|
|
24
|
+
# end
|
|
25
|
+
# output :total
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# invariant(:total_non_negative) { |total:, **| total >= 0 }
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# This module is applied globally via:
|
|
32
|
+
# Igniter::Contract.include(Igniter::Extensions::Invariants)
|
|
33
|
+
#
|
|
34
|
+
module Invariants
|
|
35
|
+
def self.included(base)
|
|
36
|
+
base.extend(ClassMethods)
|
|
37
|
+
base.prepend(InstanceMethods)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# ── Class-level DSL ──────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
module ClassMethods
|
|
43
|
+
# Declare a condition that must always hold after successful execution.
|
|
44
|
+
#
|
|
45
|
+
# The block receives the contract's declared output values as keyword
|
|
46
|
+
# arguments; use ** to absorb outputs you don't care about. Return a
|
|
47
|
+
# truthy value to indicate the invariant holds, falsy to indicate
|
|
48
|
+
# a violation.
|
|
49
|
+
#
|
|
50
|
+
# @example
|
|
51
|
+
# invariant(:positive_total) { |total:, **| total >= 0 }
|
|
52
|
+
#
|
|
53
|
+
# @param name [Symbol]
|
|
54
|
+
def invariant(name, &block)
|
|
55
|
+
@_invariants ||= {}
|
|
56
|
+
@_invariants[name.to_sym] = Igniter::Invariant.new(name, &block)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Hash{Symbol => Igniter::Invariant}]
|
|
60
|
+
def invariants
|
|
61
|
+
@_invariants || {}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# ── Instance override + new public method ───────────────────────────────
|
|
66
|
+
|
|
67
|
+
module InstanceMethods
|
|
68
|
+
# Intercepts resolve_all to run invariant checks after execution.
|
|
69
|
+
# Uses a thread-local flag so that property testing can disable the
|
|
70
|
+
# automatic raise and collect violations as data instead.
|
|
71
|
+
def resolve_all(...)
|
|
72
|
+
result = super
|
|
73
|
+
validate_invariants! unless Thread.current[:igniter_skip_invariants]
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Run all invariants without raising. Returns violations as data.
|
|
78
|
+
# Safe to call at any time after execution.
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<Igniter::InvariantViolation>]
|
|
81
|
+
def check_invariants
|
|
82
|
+
return [] if self.class.invariants.empty?
|
|
83
|
+
|
|
84
|
+
resolved = collect_output_values
|
|
85
|
+
self.class.invariants.values.filter_map { |inv| inv.check(resolved) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def validate_invariants!
|
|
91
|
+
violations = check_invariants
|
|
92
|
+
return if violations.empty?
|
|
93
|
+
|
|
94
|
+
names = violations.map { |v| ":#{v.name}" }.join(", ")
|
|
95
|
+
raise Igniter::InvariantError.new(
|
|
96
|
+
"#{violations.size} invariant(s) violated: #{names}",
|
|
97
|
+
violations: violations,
|
|
98
|
+
context: { contract: self.class.name }
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Collect all declared output values from the resolved cache.
|
|
103
|
+
# Keyed by output name (not source node name).
|
|
104
|
+
def collect_output_values
|
|
105
|
+
cache = execution.cache
|
|
106
|
+
execution.compiled_graph.outputs.each_with_object({}) do |output_node, acc|
|
|
107
|
+
state = cache.fetch(output_node.source_root)
|
|
108
|
+
acc[output_node.name] = state.value if state&.succeeded?
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
Igniter::Contract.include(Igniter::Extensions::Invariants)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../provenance"
|
|
4
|
+
|
|
5
|
+
module Igniter
|
|
6
|
+
module Extensions
|
|
7
|
+
# Adds runtime provenance (data lineage) methods to Igniter::Contract.
|
|
8
|
+
#
|
|
9
|
+
# After requiring this file, every resolved contract gains two new methods:
|
|
10
|
+
#
|
|
11
|
+
# contract.resolve_all
|
|
12
|
+
#
|
|
13
|
+
# # Full Lineage object — query API
|
|
14
|
+
# lin = contract.lineage(:grand_total)
|
|
15
|
+
# lin.contributing_inputs # => { base_price: 100.0, quantity: 2 }
|
|
16
|
+
# lin.sensitive_to?(:base_price) # => true
|
|
17
|
+
# lin.path_to(:base_price) # => [:grand_total, :subtotal, :base_price]
|
|
18
|
+
# lin.to_h # serialisable Hash
|
|
19
|
+
#
|
|
20
|
+
# # Shorthand: human-readable ASCII tree printed to stdout
|
|
21
|
+
# contract.explain(:grand_total)
|
|
22
|
+
#
|
|
23
|
+
module Provenance
|
|
24
|
+
# Return a Lineage object for the named output.
|
|
25
|
+
# Raises ProvenanceError if the output is unknown or the contract has not
|
|
26
|
+
# been executed (resolve_all has not been called).
|
|
27
|
+
def lineage(output_name)
|
|
28
|
+
unless execution
|
|
29
|
+
raise Igniter::Provenance::ProvenanceError,
|
|
30
|
+
"Contract has not been executed — call resolve_all first"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Igniter::Provenance::Builder.build(output_name, execution)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Return a human-readable ASCII tree explaining how +output_name+ was derived.
|
|
37
|
+
def explain(output_name)
|
|
38
|
+
lineage(output_name).explain
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Patch Igniter::Contract so every contract instance gains the methods.
|
|
45
|
+
Igniter::Contract.include(Igniter::Extensions::Provenance)
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "igniter"
|
|
4
|
+
require "igniter/saga"
|
|
5
|
+
|
|
6
|
+
module Igniter
|
|
7
|
+
module Extensions
|
|
8
|
+
# Adds saga/compensating-transaction support to all Igniter contracts.
|
|
9
|
+
#
|
|
10
|
+
# Class-level DSL:
|
|
11
|
+
# compensate :node_name do |inputs:, value:| ... end
|
|
12
|
+
#
|
|
13
|
+
# Instance methods added:
|
|
14
|
+
# resolve_saga → Igniter::Saga::Result
|
|
15
|
+
#
|
|
16
|
+
# Applied globally via:
|
|
17
|
+
# Igniter::Contract.include(Igniter::Extensions::Saga)
|
|
18
|
+
#
|
|
19
|
+
module Saga
|
|
20
|
+
def self.included(base)
|
|
21
|
+
base.extend(ClassMethods)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# ── Class-level DSL ────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
module ClassMethods
|
|
27
|
+
# Declare a compensating action for a compute node.
|
|
28
|
+
#
|
|
29
|
+
# The block is called with:
|
|
30
|
+
# inputs: — Hash of the node's dependency values
|
|
31
|
+
# value: — the value the node produced (now being rolled back)
|
|
32
|
+
#
|
|
33
|
+
# @param node_name [Symbol]
|
|
34
|
+
def compensate(node_name, &block)
|
|
35
|
+
@_compensations ||= {}
|
|
36
|
+
@_compensations[node_name.to_sym] = Igniter::Saga::Compensation.new(node_name, &block)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Hash{ Symbol => Compensation }]
|
|
40
|
+
def compensations
|
|
41
|
+
@_compensations || {}
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ── Instance methods ───────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
# Execute the contract and handle compensations on failure.
|
|
48
|
+
#
|
|
49
|
+
# Unlike `resolve_all` (which raises on failure), `resolve_saga`:
|
|
50
|
+
# - Returns a successful Result when execution completes
|
|
51
|
+
# - Catches Igniter::Error, runs compensations, returns a failed Result
|
|
52
|
+
#
|
|
53
|
+
# @return [Igniter::Saga::Result]
|
|
54
|
+
def resolve_saga # rubocop:disable Metrics/MethodLength
|
|
55
|
+
resolve_all
|
|
56
|
+
Igniter::Saga::Result.new(success: true, contract: self)
|
|
57
|
+
rescue Igniter::Error => e
|
|
58
|
+
executor = Igniter::Saga::Executor.new(self)
|
|
59
|
+
compensations_ran = executor.run_compensations
|
|
60
|
+
failed_node = executor.failed_node_name
|
|
61
|
+
|
|
62
|
+
Igniter::Saga::Result.new(
|
|
63
|
+
success: false,
|
|
64
|
+
contract: self,
|
|
65
|
+
error: e,
|
|
66
|
+
failed_node: failed_node,
|
|
67
|
+
compensations: compensations_ran
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Igniter::Contract.include(Igniter::Extensions::Saga)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Actor system for Igniter — stateful message-driven agents with supervision.
|
|
4
|
+
#
|
|
5
|
+
# Usage:
|
|
6
|
+
# require "igniter/integrations/agents"
|
|
7
|
+
#
|
|
8
|
+
# Provides:
|
|
9
|
+
# Igniter::Agent — base class for stateful actors
|
|
10
|
+
# Igniter::Supervisor — supervises and restarts child agents
|
|
11
|
+
# Igniter::Registry — thread-safe name → Ref lookup
|
|
12
|
+
# Igniter::StreamLoop — continuous contract-in-a-tick-loop
|
|
13
|
+
#
|
|
14
|
+
|
|
15
|
+
require_relative "../agent"
|
|
16
|
+
require_relative "../supervisor"
|
|
17
|
+
require_relative "../registry"
|
|
18
|
+
require_relative "../stream_loop"
|