smith-agents 0.4.4 → 0.5.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 +262 -0
- data/README.md +116 -6
- data/docs/CONFIGURATION.md +4 -1
- data/docs/PATTERNS.md +7 -0
- data/docs/PERSISTENCE.md +502 -1
- data/docs/workflow_claim.md +4 -1
- data/lib/smith/agent/lifecycle.rb +19 -10
- data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
- data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
- data/lib/smith/agent/registry.rb +8 -0
- data/lib/smith/agent.rb +24 -2
- data/lib/smith/budget/amount_contract.rb +67 -0
- data/lib/smith/budget/amount_representation.rb +43 -0
- data/lib/smith/budget/decimal_context.rb +18 -0
- data/lib/smith/budget/ledger.rb +110 -18
- data/lib/smith/budget/ledger_publication.rb +60 -0
- data/lib/smith/budget/ledger_state.rb +21 -0
- data/lib/smith/budget/ledger_state_transition.rb +72 -0
- data/lib/smith/budget/public_state_validator.rb +25 -0
- data/lib/smith/budget/reservation.rb +20 -0
- data/lib/smith/budget/reservation_contract.rb +34 -0
- data/lib/smith/context/observation_masking.rb +3 -1
- data/lib/smith/context/state_injection.rb +1 -1
- data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -3
- data/lib/smith/error.rb +5 -0
- data/lib/smith/errors.rb +6 -0
- data/lib/smith/exponential_backoff.rb +128 -0
- data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
- data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
- data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
- data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
- data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
- data/lib/smith/persistence_adapters/active_record_store.rb +95 -52
- data/lib/smith/persistence_adapters/cache_store.rb +4 -1
- data/lib/smith/persistence_adapters/memory.rb +45 -14
- data/lib/smith/persistence_adapters/payload_version.rb +23 -0
- data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
- data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
- data/lib/smith/persistence_adapters/redis_store.rb +38 -43
- data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
- data/lib/smith/persistence_adapters/retry.rb +22 -10
- data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
- data/lib/smith/persistence_adapters.rb +26 -18
- data/lib/smith/persistence_payload_conflict.rb +12 -0
- data/lib/smith/tool/budget_enforcement.rb +2 -2
- data/lib/smith/types.rb +1 -0
- data/lib/smith/version.rb +2 -1
- data/lib/smith/workflow/branch_env.rb +1 -1
- data/lib/smith/workflow/budget_integration.rb +21 -11
- data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
- data/lib/smith/workflow/definition_identity.rb +25 -0
- data/lib/smith/workflow/deterministic_step.rb +2 -2
- data/lib/smith/workflow/dsl.rb +73 -5
- data/lib/smith/workflow/durability.rb +2 -1
- data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
- data/lib/smith/workflow/execution.rb +16 -18
- data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
- data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
- data/lib/smith/workflow/fanout_execution.rb +1 -1
- data/lib/smith/workflow/graph/diagnostic.rb +27 -1
- data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
- data/lib/smith/workflow/graph/execution_successors.rb +65 -0
- data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
- data/lib/smith/workflow/graph/metrics.rb +1 -3
- data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
- data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
- data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
- data/lib/smith/workflow/graph/reachability.rb +19 -31
- data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
- data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
- data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
- data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
- data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
- data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
- data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
- data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
- data/lib/smith/workflow/graph/transition_contract.rb +47 -0
- data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
- data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
- data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
- data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
- data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
- data/lib/smith/workflow/graph/validator.rb +1 -1
- data/lib/smith/workflow/graph.rb +62 -4
- data/lib/smith/workflow/guardrail_integration.rb +23 -9
- data/lib/smith/workflow/identifier.rb +24 -0
- data/lib/smith/workflow/message_admission.rb +40 -0
- data/lib/smith/workflow/message_admission_boundary.rb +22 -0
- data/lib/smith/workflow/message_batch.rb +60 -0
- data/lib/smith/workflow/message_value_normalizer.rb +133 -0
- data/lib/smith/workflow/nested_execution.rb +10 -0
- data/lib/smith/workflow/orchestrator_worker.rb +3 -7
- data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
- data/lib/smith/workflow/parallel/execution_context.rb +113 -0
- data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
- data/lib/smith/workflow/parallel/root_execution.rb +91 -0
- data/lib/smith/workflow/parallel.rb +19 -22
- data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
- data/lib/smith/workflow/parallel_execution.rb +7 -3
- data/lib/smith/workflow/persistence.rb +49 -10
- data/lib/smith/workflow/prepared_step.rb +122 -0
- data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
- data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
- data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
- data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
- data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
- data/lib/smith/workflow/retry_execution.rb +20 -11
- data/lib/smith/workflow/split_step_persistence/boundary.rb +128 -0
- data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
- data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint.rb +119 -0
- data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
- data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
- data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
- data/lib/smith/workflow/split_step_persistence/execution.rb +128 -0
- data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
- data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
- data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
- data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
- data/lib/smith/workflow/split_step_persistence/inheritance.rb +22 -0
- data/lib/smith/workflow/split_step_persistence/payloads.rb +54 -0
- data/lib/smith/workflow/split_step_persistence/preparation.rb +125 -0
- data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +117 -0
- data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
- data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +54 -0
- data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
- data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
- data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
- data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
- data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
- data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
- data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +102 -0
- data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
- data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
- data/lib/smith/workflow/split_step_persistence.rb +79 -0
- data/lib/smith/workflow/step_completion.rb +31 -0
- data/lib/smith/workflow/string_snapshot.rb +25 -0
- data/lib/smith/workflow/transition.rb +122 -133
- data/lib/smith/workflow/transition_actionability.rb +22 -0
- data/lib/smith/workflow.rb +57 -19
- data/lib/smith.rb +42 -2
- data/sig/smith.rbs +1 -0
- metadata +131 -19
data/lib/smith/agent/registry.rb
CHANGED
|
@@ -3,11 +3,15 @@
|
|
|
3
3
|
require "dry-container"
|
|
4
4
|
require "monitor"
|
|
5
5
|
require_relative "registry_binding"
|
|
6
|
+
require_relative "registry/execution_binding_capture"
|
|
7
|
+
require_relative "registry/mutation_boundary"
|
|
6
8
|
|
|
7
9
|
module Smith
|
|
8
10
|
class Agent
|
|
9
11
|
module Registry
|
|
10
12
|
extend Dry::Container::Mixin
|
|
13
|
+
extend ExecutionBindingCapture
|
|
14
|
+
extend MutationBoundary
|
|
11
15
|
|
|
12
16
|
def self.normalize_key(name)
|
|
13
17
|
name.to_s
|
|
@@ -126,6 +130,10 @@ module Smith
|
|
|
126
130
|
existing_name == klass_name
|
|
127
131
|
end
|
|
128
132
|
private_class_method :stale_reload_binding?
|
|
133
|
+
|
|
134
|
+
class << self
|
|
135
|
+
private :_container, :config
|
|
136
|
+
end
|
|
129
137
|
end
|
|
130
138
|
end
|
|
131
139
|
end
|
data/lib/smith/agent.rb
CHANGED
|
@@ -56,10 +56,19 @@ module Smith
|
|
|
56
56
|
@fallback_models_list = entries.uniq
|
|
57
57
|
end
|
|
58
58
|
|
|
59
|
-
def register_as(name = nil)
|
|
59
|
+
def register_as(name = nil, publish: true)
|
|
60
60
|
return @registered_name if name.nil?
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
raise ArgumentError, "publish must be true or false" unless [true, false].include?(publish)
|
|
63
|
+
|
|
64
|
+
@registered_name = canonical_registration_name(name)
|
|
65
|
+
publish ? publish_registration! : self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def publish_registration!
|
|
69
|
+
name = @registered_name
|
|
70
|
+
raise Smith::AgentRegistryError, "agent registration identity is not configured" unless name
|
|
71
|
+
|
|
63
72
|
Registry.ensure_registered(name.to_sym, self)
|
|
64
73
|
end
|
|
65
74
|
|
|
@@ -109,6 +118,19 @@ module Smith
|
|
|
109
118
|
|
|
110
119
|
attr_reader :model_block
|
|
111
120
|
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def canonical_registration_name(name)
|
|
124
|
+
symbol = name.to_sym
|
|
125
|
+
raise TypeError, "agent registration name must convert to a Symbol" unless symbol.is_a?(Symbol)
|
|
126
|
+
|
|
127
|
+
name.is_a?(String) ? name.dup.freeze : symbol
|
|
128
|
+
rescue NoMethodError
|
|
129
|
+
raise TypeError, "agent registration name must respond to #to_sym"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
public
|
|
133
|
+
|
|
112
134
|
# Whether this agent class has any model configured (static or block).
|
|
113
135
|
# Smith::Workflow::Execution uses this as a precondition for invoking
|
|
114
136
|
# the agent; agents declared without a model are skipped.
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Budget
|
|
7
|
+
class AmountContract
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :limits, default: proc {}
|
|
11
|
+
|
|
12
|
+
def own_hash!(amounts, label)
|
|
13
|
+
case amounts
|
|
14
|
+
when Hash then own_amount_hash(amounts, label)
|
|
15
|
+
else raise ArgumentError, "#{label} values must be a Hash"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def known_hash!(amounts)
|
|
20
|
+
amounts.each_key { known_dimension!(_1) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def known_dimension!(key)
|
|
24
|
+
return if limits&.key?(key)
|
|
25
|
+
|
|
26
|
+
raise ArgumentError, "unknown budget dimension #{key.inspect}"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def matching_dimensions!(reserved, actual)
|
|
30
|
+
return if reserved.length == actual.length && reserved.each_key.all? { actual.key?(_1) }
|
|
31
|
+
|
|
32
|
+
raise ArgumentError, "reserved and actual budget dimensions must match"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def dimension!(key)
|
|
36
|
+
case key
|
|
37
|
+
when Symbol then key
|
|
38
|
+
when String then String.new(key).freeze
|
|
39
|
+
else raise ArgumentError, "budget dimension keys must be symbols or strings"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def own_amount_hash(amounts, label)
|
|
46
|
+
{}.tap do |owned|
|
|
47
|
+
Hash.instance_method(:each_pair).bind_call(amounts) do |key, amount|
|
|
48
|
+
owned[dimension!(key)] = own_amount!(amount, label)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def own_amount!(amount, label)
|
|
54
|
+
validate_amount!(amount, label)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def validate_amount!(amount, label)
|
|
58
|
+
case amount
|
|
59
|
+
when Integer then return amount if amount >= 0
|
|
60
|
+
when Float then return amount if amount.finite? && amount >= 0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
raise ArgumentError, "#{label} values must be finite and non-negative Integer or Float values"
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "dry-initializer"
|
|
5
|
+
|
|
6
|
+
module Smith
|
|
7
|
+
module Budget
|
|
8
|
+
class AmountRepresentation
|
|
9
|
+
extend Dry::Initializer
|
|
10
|
+
|
|
11
|
+
option :limits
|
|
12
|
+
|
|
13
|
+
def internalize(amounts)
|
|
14
|
+
amounts.to_h { |key, amount| [key, internalize_amount(amount)] }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def externalize(amounts)
|
|
18
|
+
amounts.to_h { |key, amount| [key, externalize_amount(key, amount)] }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def externalize_amount(key, amount)
|
|
22
|
+
return finite_float!(amount) if limits.fetch(key).is_a?(Float)
|
|
23
|
+
return amount if amount.is_a?(Integer)
|
|
24
|
+
return amount.to_i if amount.frac.zero?
|
|
25
|
+
|
|
26
|
+
finite_float!(amount)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def internalize_amount(amount)
|
|
32
|
+
BigDecimal(amount.to_s)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def finite_float!(amount)
|
|
36
|
+
external = amount.to_f
|
|
37
|
+
return external if external.finite?
|
|
38
|
+
|
|
39
|
+
raise ArgumentError, "budget state values must remain JSON-safe finite numerics"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Budget
|
|
7
|
+
class DecimalContext
|
|
8
|
+
def self.call
|
|
9
|
+
BigDecimal.save_limit do
|
|
10
|
+
BigDecimal.limit(0)
|
|
11
|
+
yield
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private_class_method :new
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/smith/budget/ledger.rb
CHANGED
|
@@ -1,41 +1,133 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "amount_contract"
|
|
4
|
+
require_relative "amount_representation"
|
|
5
|
+
require_relative "ledger_publication"
|
|
6
|
+
require_relative "ledger_state"
|
|
7
|
+
require_relative "ledger_state_transition"
|
|
8
|
+
require_relative "public_state_validator"
|
|
9
|
+
require_relative "reservation"
|
|
10
|
+
require_relative "reservation_contract"
|
|
11
|
+
|
|
3
12
|
module Smith
|
|
4
13
|
module Budget
|
|
5
14
|
class Ledger
|
|
6
|
-
attr_reader :limits
|
|
15
|
+
attr_reader :limits
|
|
7
16
|
|
|
8
17
|
def initialize(limits: {}, consumed: {})
|
|
9
18
|
@mutex = Mutex.new
|
|
10
|
-
@
|
|
11
|
-
@
|
|
12
|
-
|
|
19
|
+
@identity = Object.new.freeze
|
|
20
|
+
@reservation_contract = ReservationContract.new(ledger_identity: @identity)
|
|
21
|
+
initialize_contracts(limits)
|
|
22
|
+
initialize_state(consumed)
|
|
13
23
|
end
|
|
14
24
|
|
|
15
|
-
def
|
|
16
|
-
@mutex.synchronize
|
|
17
|
-
|
|
18
|
-
raise BudgetExceeded if committed + amount > @limits[key]
|
|
25
|
+
def consumed
|
|
26
|
+
@mutex.synchronize { @representation.externalize(@publication.state.consumed).freeze }
|
|
27
|
+
end
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
def reserve!(key, amount) = reserve_many!(key => amount)
|
|
30
|
+
|
|
31
|
+
def reserve_many!(reservations)
|
|
32
|
+
amounts = validated_amounts(reservations, "budget reservation")
|
|
33
|
+
@mutex.synchronize { create_reservation(amounts) }
|
|
22
34
|
end
|
|
23
35
|
|
|
24
|
-
def reconcile!(
|
|
25
|
-
@
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
def reconcile!(reservation, actual_amount)
|
|
37
|
+
amounts = @reservation_contract.amounts!(reservation)
|
|
38
|
+
unless amounts.one?
|
|
39
|
+
raise ArgumentError, "single-dimension reconciliation requires a single-dimension reservation"
|
|
28
40
|
end
|
|
41
|
+
|
|
42
|
+
reconcile_many!(reservation, actual: { amounts.keys.first => actual_amount }).values.first
|
|
29
43
|
end
|
|
30
44
|
|
|
31
|
-
def
|
|
45
|
+
def reconcile_many!(reservation, actual:)
|
|
46
|
+
token = @reservation_contract.token!(reservation)
|
|
47
|
+
actuals = validated_amounts(actual, "actual budget")
|
|
48
|
+
@mutex.synchronize { settle_reservation(token, actuals) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def release!(reservation)
|
|
52
|
+
released = release_many!(reservation)
|
|
53
|
+
released.values.first if released.one?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def release_many!(reservation)
|
|
57
|
+
token = @reservation_contract.token!(reservation)
|
|
58
|
+
@mutex.synchronize { release_reservation(token) }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def remaining(key)
|
|
62
|
+
dimension = @amount_contract.dimension!(key)
|
|
63
|
+
@amount_contract.known_dimension!(dimension)
|
|
32
64
|
@mutex.synchronize do
|
|
33
|
-
|
|
65
|
+
amount = @state_transition.remaining(@publication.state, dimension)
|
|
66
|
+
@representation.externalize_amount(dimension, amount)
|
|
34
67
|
end
|
|
35
68
|
end
|
|
36
69
|
|
|
37
|
-
|
|
38
|
-
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def initialize_contracts(limits)
|
|
73
|
+
@limits = AmountContract.new.own_hash!(limits, "budget limit").freeze
|
|
74
|
+
@amount_contract = AmountContract.new(limits: @limits)
|
|
75
|
+
@representation = AmountRepresentation.new(limits: @limits)
|
|
76
|
+
internal_limits = @representation.internalize(@limits)
|
|
77
|
+
@state_transition = LedgerStateTransition.new(limits: internal_limits)
|
|
78
|
+
@public_state_validator = PublicStateValidator.new(
|
|
79
|
+
limits: @limits,
|
|
80
|
+
representation: @representation,
|
|
81
|
+
state_transition: @state_transition
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def initialize_state(consumed)
|
|
86
|
+
initial_consumed = @amount_contract.own_hash!(consumed, "consumed budget")
|
|
87
|
+
@amount_contract.known_hash!(initial_consumed)
|
|
88
|
+
internal_consumed = @representation.internalize(initial_consumed)
|
|
89
|
+
state = LedgerState.new(consumed: internal_consumed, reserved: {})
|
|
90
|
+
@publication = LedgerPublication.new(state: @public_state_validator.call(state))
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def validated_amounts(amounts, label)
|
|
94
|
+
external = @amount_contract.own_hash!(amounts, label)
|
|
95
|
+
@amount_contract.known_hash!(external)
|
|
96
|
+
@representation.internalize(external)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def create_reservation(amounts)
|
|
100
|
+
token = Object.new.freeze
|
|
101
|
+
receipt = Reservation.new(
|
|
102
|
+
ledger_identity: @identity,
|
|
103
|
+
token:,
|
|
104
|
+
amounts: @representation.externalize(amounts)
|
|
105
|
+
)
|
|
106
|
+
next_state = @state_transition.reserve(@publication.state, amounts)
|
|
107
|
+
@publication.add(@public_state_validator.call(next_state), token:, amounts:)
|
|
108
|
+
receipt
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def settle_reservation(token, actuals)
|
|
112
|
+
reserved_amounts = active_reservation!(token)
|
|
113
|
+
@amount_contract.matching_dimensions!(reserved_amounts, actuals)
|
|
114
|
+
next_state = @state_transition.reconcile(@publication.state, reserved_amounts, actuals)
|
|
115
|
+
consumed = @representation.externalize(next_state.consumed.slice(*actuals.keys))
|
|
116
|
+
@publication.remove(@public_state_validator.call(next_state), token:, amounts: reserved_amounts)
|
|
117
|
+
consumed
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def release_reservation(token)
|
|
121
|
+
reserved_amounts = active_reservation!(token)
|
|
122
|
+
next_state = @state_transition.release(@publication.state, reserved_amounts)
|
|
123
|
+
remaining = @representation.externalize(next_state.reserved.slice(*reserved_amounts.keys))
|
|
124
|
+
@publication.remove(@public_state_validator.call(next_state), token:, amounts: reserved_amounts)
|
|
125
|
+
remaining
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def active_reservation!(token)
|
|
129
|
+
@publication.reservation(token) ||
|
|
130
|
+
raise(ArgumentError, "budget reservation is unknown or already settled")
|
|
39
131
|
end
|
|
40
132
|
end
|
|
41
133
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Budget
|
|
7
|
+
class LedgerPublication
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :state
|
|
11
|
+
option :reservations, default: proc { {} }
|
|
12
|
+
|
|
13
|
+
private :reservations
|
|
14
|
+
|
|
15
|
+
def reservation(token)
|
|
16
|
+
reservations[token]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def add(next_state, token:, amounts:)
|
|
20
|
+
publish(
|
|
21
|
+
next_state,
|
|
22
|
+
commit: -> { reservations[token] = amounts },
|
|
23
|
+
rollback: -> { reservations.delete(token) }
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def remove(next_state, token:, amounts:)
|
|
28
|
+
publish(
|
|
29
|
+
next_state,
|
|
30
|
+
commit: -> { reservations.delete(token) },
|
|
31
|
+
rollback: -> { reservations[token] = amounts }
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def publish(next_state, commit:, rollback:)
|
|
38
|
+
previous_state = state
|
|
39
|
+
published = false
|
|
40
|
+
begin
|
|
41
|
+
Thread.handle_interrupt(Object => :never) do
|
|
42
|
+
@state = next_state
|
|
43
|
+
published = true
|
|
44
|
+
commit.call
|
|
45
|
+
end
|
|
46
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
47
|
+
rollback(previous_state, rollback) if published
|
|
48
|
+
raise
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def rollback(previous_state, operation)
|
|
53
|
+
Thread.handle_interrupt(Object => :never) do
|
|
54
|
+
@state = previous_state
|
|
55
|
+
operation.call
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Budget
|
|
5
|
+
class LedgerState
|
|
6
|
+
attr_reader :consumed, :reserved
|
|
7
|
+
|
|
8
|
+
def initialize(consumed:, reserved:)
|
|
9
|
+
@consumed = amount_hash(consumed)
|
|
10
|
+
@reserved = amount_hash(reserved)
|
|
11
|
+
freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def amount_hash(amounts)
|
|
17
|
+
Hash.new(0).merge(amounts).freeze
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
require "dry-initializer"
|
|
5
|
+
require_relative "decimal_context"
|
|
6
|
+
|
|
7
|
+
module Smith
|
|
8
|
+
module Budget
|
|
9
|
+
class LedgerStateTransition
|
|
10
|
+
extend Dry::Initializer
|
|
11
|
+
|
|
12
|
+
option :limits
|
|
13
|
+
|
|
14
|
+
def reserve(state, amounts)
|
|
15
|
+
DecimalContext.call do
|
|
16
|
+
amounts.each do |key, amount|
|
|
17
|
+
requested = state.consumed[key] + state.reserved[key] + amount
|
|
18
|
+
raise BudgetExceeded unless requested.finite? && requested <= limits.fetch(key)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
LedgerState.new(consumed: state.consumed, reserved: add(state.reserved, amounts))
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def reconcile(state, reserved_amounts, actuals)
|
|
26
|
+
DecimalContext.call do
|
|
27
|
+
LedgerState.new(
|
|
28
|
+
consumed: add(state.consumed, actuals),
|
|
29
|
+
reserved: subtract(state.reserved, reserved_amounts)
|
|
30
|
+
)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def release(state, reserved_amounts)
|
|
35
|
+
DecimalContext.call do
|
|
36
|
+
LedgerState.new(
|
|
37
|
+
consumed: state.consumed,
|
|
38
|
+
reserved: subtract(state.reserved, reserved_amounts)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def remaining(state, key)
|
|
44
|
+
DecimalContext.call do
|
|
45
|
+
amount = limits.fetch(key) - state.consumed[key] - state.reserved[key]
|
|
46
|
+
amount.negative? ? BigDecimal("0") : amount
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def add(current, amounts)
|
|
53
|
+
merge(current, amounts) { |value, amount| value + amount }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def subtract(current, amounts)
|
|
57
|
+
merge(current, amounts) { |value, amount| value - amount }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def merge(current, amounts)
|
|
61
|
+
current.dup.tap do |result|
|
|
62
|
+
amounts.each do |key, amount|
|
|
63
|
+
value = yield(current.fetch(key, 0), amount)
|
|
64
|
+
raise ArgumentError, "budget state values must be non-negative" if value.negative?
|
|
65
|
+
|
|
66
|
+
result[key] = value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Budget
|
|
7
|
+
class PublicStateValidator
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :limits
|
|
11
|
+
option :representation
|
|
12
|
+
option :state_transition
|
|
13
|
+
|
|
14
|
+
def call(state)
|
|
15
|
+
representation.externalize(state.consumed)
|
|
16
|
+
representation.externalize(state.reserved)
|
|
17
|
+
limits.each_key do |dimension|
|
|
18
|
+
remaining = state_transition.remaining(state, dimension)
|
|
19
|
+
representation.externalize_amount(dimension, remaining)
|
|
20
|
+
end
|
|
21
|
+
state
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Smith
|
|
4
|
+
module Budget
|
|
5
|
+
class Reservation
|
|
6
|
+
attr_reader :amounts
|
|
7
|
+
|
|
8
|
+
def initialize(ledger_identity:, token:, amounts:)
|
|
9
|
+
@ledger_identity = ledger_identity
|
|
10
|
+
@token = token
|
|
11
|
+
@amounts = amounts.dup.freeze
|
|
12
|
+
freeze
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def identity_for(ledger_identity)
|
|
16
|
+
@token if @ledger_identity.equal?(ledger_identity)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-initializer"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
module Budget
|
|
7
|
+
class ReservationContract
|
|
8
|
+
extend Dry::Initializer
|
|
9
|
+
|
|
10
|
+
option :ledger_identity
|
|
11
|
+
|
|
12
|
+
def token!(reservation)
|
|
13
|
+
validate_receipt!(reservation)
|
|
14
|
+
reservation.identity_for(ledger_identity) ||
|
|
15
|
+
raise(ArgumentError, "budget reservation belongs to another ledger")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def amounts!(reservation)
|
|
19
|
+
token!(reservation)
|
|
20
|
+
reservation.amounts
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def validate_receipt!(reservation)
|
|
26
|
+
case reservation
|
|
27
|
+
when Reservation then return
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
raise ArgumentError, "budget settlement requires a reservation receipt"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -11,7 +11,9 @@ module Smith
|
|
|
11
11
|
window = strategy[:window]
|
|
12
12
|
return messages unless window
|
|
13
13
|
|
|
14
|
-
system_msgs, non_system = messages.partition
|
|
14
|
+
system_msgs, non_system = messages.partition do |message|
|
|
15
|
+
SYSTEM_ROLES.include?(message[:role] || message["role"])
|
|
16
|
+
end
|
|
15
17
|
system_msgs + non_system.last(window)
|
|
16
18
|
end
|
|
17
19
|
end
|
|
@@ -9,7 +9,7 @@ module Smith
|
|
|
9
9
|
content = "#{MARKER}\n#{formatter.call(persisted)}"
|
|
10
10
|
|
|
11
11
|
existing_index = messages.index do |message|
|
|
12
|
-
message_content = message[:content]
|
|
12
|
+
message_content = message[:content] || message["content"]
|
|
13
13
|
message_content.is_a?(String) && message_content.start_with?(MARKER)
|
|
14
14
|
end
|
|
15
15
|
|
|
@@ -20,7 +20,7 @@ module Smith
|
|
|
20
20
|
adapter = resolve_adapter
|
|
21
21
|
return report_missing_adapter(report) if adapter.nil?
|
|
22
22
|
|
|
23
|
-
missing = OPTIONAL_CAPABILITIES.reject { |cap|
|
|
23
|
+
missing = OPTIONAL_CAPABILITIES.reject { |cap| capability_available?(adapter, cap) }
|
|
24
24
|
return report_supported_capabilities(report, adapter) if missing.empty?
|
|
25
25
|
|
|
26
26
|
report_missing_capabilities(report, adapter, missing)
|
|
@@ -32,6 +32,16 @@ module Smith
|
|
|
32
32
|
nil
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
+
def capability_available?(adapter, capability)
|
|
36
|
+
return false unless Smith::PersistenceAdapters.supports?(adapter, capability)
|
|
37
|
+
return true unless capability == :persistence_identity
|
|
38
|
+
|
|
39
|
+
identity = adapter.persistence_identity
|
|
40
|
+
identity.is_a?(String) && !identity.empty? && identity.bytesize <= 256
|
|
41
|
+
rescue StandardError
|
|
42
|
+
false
|
|
43
|
+
end
|
|
44
|
+
|
|
35
45
|
def report_missing_adapter(report)
|
|
36
46
|
report.add(
|
|
37
47
|
name: "persistence.capabilities",
|
|
@@ -58,8 +68,13 @@ module Smith
|
|
|
58
68
|
message: "#{adapter.class.name} missing optional capabilities: #{missing.join(", ")}",
|
|
59
69
|
detail: "Smith will fall back where possible: non-versioned writes when store_versioned " \
|
|
60
70
|
"is missing, and payload updated_at parsing when heartbeat methods are missing. " \
|
|
61
|
-
"
|
|
62
|
-
"
|
|
71
|
+
"Without transaction_open? and transaction_identity, exact commit-aware " \
|
|
72
|
+
"split-step confirmation is unavailable. " \
|
|
73
|
+
"If transaction_open? returns true, transaction_identity is required and " \
|
|
74
|
+
"split-step preparation fails before writing when it is missing. " \
|
|
75
|
+
"Use RedisStore with an explicit persistence identity or Memory for full versioning " \
|
|
76
|
+
"and heartbeat coverage; ActiveRecordStore covers optimistic locking and exact " \
|
|
77
|
+
"transaction identity when configured with an identity."
|
|
63
78
|
)
|
|
64
79
|
end
|
|
65
80
|
end
|