smith-agents 0.4.5 → 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 +201 -0
- data/README.md +108 -4
- data/docs/CONFIGURATION.md +4 -1
- data/docs/PATTERNS.md +7 -0
- data/docs/PERSISTENCE.md +347 -25
- 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 -4
- 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_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_store.rb +7 -4
- data/lib/smith/persistence_adapters/cache_store.rb +1 -0
- data/lib/smith/persistence_adapters/memory.rb +16 -1
- 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 +28 -11
- data/lib/smith/persistence_adapters/retry.rb +22 -10
- data/lib/smith/persistence_adapters.rb +21 -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 +1 -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 +37 -8
- 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 +35 -22
- 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 +3 -5
- 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 +49 -47
- 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 +3 -1
- data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
- data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
- data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
- data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
- data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
- 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/subclass_boundary.rb +50 -0
- data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
- data/lib/smith/workflow/split_step_persistence.rb +36 -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 +44 -18
- data/lib/smith.rb +41 -2
- data/sig/smith.rbs +1 -0
- metadata +110 -19
|
@@ -10,17 +10,17 @@ module Smith
|
|
|
10
10
|
class: self.class.name,
|
|
11
11
|
state: @state,
|
|
12
12
|
persistence_key: @persistence_key,
|
|
13
|
-
context: persisted_context,
|
|
13
|
+
context: snapshot_value(persisted_context),
|
|
14
14
|
budget_consumed: ledger_consumed,
|
|
15
15
|
step_count: @step_count,
|
|
16
16
|
execution_namespace: @execution_namespace,
|
|
17
17
|
created_at: @created_at,
|
|
18
18
|
updated_at: @updated_at,
|
|
19
19
|
next_transition_name: @next_transition_name,
|
|
20
|
-
session_messages:
|
|
20
|
+
session_messages: snapshot_session_messages,
|
|
21
21
|
total_cost: @total_cost || 0.0,
|
|
22
22
|
total_tokens: @total_tokens || 0,
|
|
23
|
-
tool_results: @tool_results || [],
|
|
23
|
+
tool_results: snapshot_value(@tool_results || []),
|
|
24
24
|
outcome: snapshot_outcome,
|
|
25
25
|
# Durable usage fields. All wrapped in
|
|
26
26
|
# snapshot_value so non-JSON-safe runtime values (e.g.
|
|
@@ -38,6 +38,11 @@ module Smith
|
|
|
38
38
|
# stored value lags the workflow's current
|
|
39
39
|
# persistence_schema_version.
|
|
40
40
|
schema_version: self.class.persistence_schema_version,
|
|
41
|
+
# Host-supplied digest of the complete executable workflow
|
|
42
|
+
# definition. Smith does not derive this from Ruby Proc or VM
|
|
43
|
+
# internals; hosts that need restart-safe prepared-step recovery
|
|
44
|
+
# must bind it to their immutable code/package identity.
|
|
45
|
+
definition_digest: effective_definition_digest,
|
|
41
46
|
# SHA256 digest of the seed_messages produced at this
|
|
42
47
|
# workflow's construction. Stays stable across persist/restore
|
|
43
48
|
# cycles so seed_validation can detect when the seed builder
|
|
@@ -59,7 +64,9 @@ module Smith
|
|
|
59
64
|
|
|
60
65
|
private
|
|
61
66
|
|
|
62
|
-
def restore_state(hash)
|
|
67
|
+
def restore_state(hash, allow_step_in_progress: false)
|
|
68
|
+
validate_raw_step_in_progress!(hash) if
|
|
69
|
+
self.class.idempotency_mode == :strict && !allow_step_in_progress
|
|
63
70
|
migrated = migrate_if_needed(hash)
|
|
64
71
|
normalized = normalize_persisted_state(migrated)
|
|
65
72
|
persistence_version = validated_persistence_version(normalized)
|
|
@@ -89,6 +96,7 @@ module Smith
|
|
|
89
96
|
# so the first persist! after restore expects version 0 (matches
|
|
90
97
|
# the original store from the legacy adapter contract).
|
|
91
98
|
@persistence_version = persistence_version
|
|
99
|
+
validate_definition_digest!(normalized)
|
|
92
100
|
# Preserve the seed digest from the persisted payload so it
|
|
93
101
|
# round-trips on subsequent persists. validate_seed_digest!
|
|
94
102
|
# compares this against a fresh evaluation of the seed builder
|
|
@@ -100,7 +108,8 @@ module Smith
|
|
|
100
108
|
# mode by raising if the marker is set on restore.
|
|
101
109
|
@step_in_progress = normalized[:step_in_progress] || false
|
|
102
110
|
@split_step_mutex = Mutex.new
|
|
103
|
-
validate_step_in_progress!(normalized) if
|
|
111
|
+
validate_step_in_progress!(normalized) if
|
|
112
|
+
self.class.idempotency_mode == :strict && !allow_step_in_progress
|
|
104
113
|
end
|
|
105
114
|
|
|
106
115
|
def validated_persistence_version(normalized)
|
|
@@ -111,6 +120,18 @@ module Smith
|
|
|
111
120
|
"persisted workflow persistence_version must be a non-negative integer, got #{version.inspect}"
|
|
112
121
|
end
|
|
113
122
|
|
|
123
|
+
def validate_definition_digest!(normalized)
|
|
124
|
+
stored = normalized[:definition_digest]
|
|
125
|
+
return if stored.nil?
|
|
126
|
+
|
|
127
|
+
unless Smith::Types::Sha256Hex.valid?(stored)
|
|
128
|
+
raise Smith::SerializationError, "persisted workflow definition_digest must be a SHA-256 hex digest"
|
|
129
|
+
end
|
|
130
|
+
return if stored == self.class.definition_digest
|
|
131
|
+
|
|
132
|
+
raise Smith::WorkflowError, "persisted workflow definition does not match the current workflow class"
|
|
133
|
+
end
|
|
134
|
+
|
|
114
135
|
def validate_step_in_progress!(normalized)
|
|
115
136
|
return unless normalized[:step_in_progress] == true
|
|
116
137
|
|
|
@@ -120,6 +141,14 @@ module Smith
|
|
|
120
141
|
)
|
|
121
142
|
end
|
|
122
143
|
|
|
144
|
+
def validate_raw_step_in_progress!(state)
|
|
145
|
+
return unless state.is_a?(Hash)
|
|
146
|
+
|
|
147
|
+
marker = state[:step_in_progress] || state["step_in_progress"]
|
|
148
|
+
persistence_key = state[:persistence_key] || state["persistence_key"]
|
|
149
|
+
validate_step_in_progress!(step_in_progress: marker, persistence_key:)
|
|
150
|
+
end
|
|
151
|
+
|
|
123
152
|
def validate_seed_digest!(normalized)
|
|
124
153
|
stored_digest = normalized[:seed_digest]
|
|
125
154
|
return if stored_digest.nil?
|
|
@@ -256,13 +285,13 @@ module Smith
|
|
|
256
285
|
end
|
|
257
286
|
|
|
258
287
|
def normalize_persisted_state(hash)
|
|
259
|
-
normalized = hash.transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
288
|
+
normalized = snapshot_value(hash).transform_keys { |k| k.is_a?(String) ? k.to_sym : k }
|
|
289
|
+
normalized[:outcome] = symbolize_value(normalized[:outcome]) if normalized[:outcome].is_a?(Hash)
|
|
260
290
|
normalize_symbol_fields!(normalized)
|
|
261
291
|
normalize_nested_hashes!(normalized)
|
|
262
292
|
normalize_session_messages!(normalized)
|
|
263
293
|
normalize_tool_results!(normalized)
|
|
264
294
|
normalize_usage_entries!(normalized)
|
|
265
|
-
normalized[:outcome] = symbolize_value(normalized[:outcome]) if normalized[:outcome].is_a?(Hash)
|
|
266
295
|
normalized
|
|
267
296
|
end
|
|
268
297
|
|
|
@@ -318,7 +347,7 @@ module Smith
|
|
|
318
347
|
return unless normalized[:session_messages].is_a?(Array)
|
|
319
348
|
|
|
320
349
|
normalized[:session_messages] = normalized[:session_messages].map do |msg|
|
|
321
|
-
msg.is_a?(Hash) ? symbolize_keys(msg) : msg
|
|
350
|
+
msg.is_a?(Hash) ? symbolize_keys(snapshot_value(msg)) : snapshot_value(msg)
|
|
322
351
|
end
|
|
323
352
|
end
|
|
324
353
|
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "../types"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
class Workflow
|
|
10
|
+
class PreparedStep < Dry::Struct
|
|
11
|
+
ATTRIBUTES = %i[
|
|
12
|
+
token transition from persistence_key persistence_version step_number preparation_digest definition_digest
|
|
13
|
+
].freeze
|
|
14
|
+
DIGEST_PATTERN = /\A[0-9a-f]{64}\z/
|
|
15
|
+
MAX_COUNTER_VALUE = (2**63) - 1
|
|
16
|
+
MAX_ATTRIBUTE_ENTRIES = 16
|
|
17
|
+
MAX_SERIALIZED_BYTES = 4 * 1024
|
|
18
|
+
UUID_PATTERN = /\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/i
|
|
19
|
+
OwnedString = Types::String.constructor do |value|
|
|
20
|
+
value.is_a?(String) ? value.dup.freeze : value
|
|
21
|
+
end
|
|
22
|
+
private_constant :OwnedString
|
|
23
|
+
|
|
24
|
+
attribute :token, OwnedString.constrained(format: UUID_PATTERN)
|
|
25
|
+
attribute :transition, OwnedString.constrained(min_size: 1)
|
|
26
|
+
attribute :from, OwnedString.constrained(min_size: 1)
|
|
27
|
+
attribute :persistence_key, OwnedString.constrained(min_size: 1)
|
|
28
|
+
attribute :persistence_version, Types::Integer.constrained(gteq: 1, lteq: MAX_COUNTER_VALUE)
|
|
29
|
+
attribute :step_number, Types::Integer.constrained(gteq: 1, lteq: MAX_COUNTER_VALUE)
|
|
30
|
+
attribute :preparation_digest, OwnedString.constrained(format: DIGEST_PATTERN)
|
|
31
|
+
attribute? :definition_digest, Types::Sha256Hex.optional
|
|
32
|
+
|
|
33
|
+
def self.deserialize(value)
|
|
34
|
+
attributes = parse_attributes(value)
|
|
35
|
+
normalized = normalize_attributes(attributes)
|
|
36
|
+
validate_attributes!(normalized)
|
|
37
|
+
validate_bounded_values!(normalized)
|
|
38
|
+
new(normalized)
|
|
39
|
+
rescue JSON::ParserError, TypeError => e
|
|
40
|
+
raise ArgumentError, "prepared step is invalid: #{e.message}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.validate_attributes!(attributes)
|
|
44
|
+
unknown = attributes.keys - ATTRIBUTES
|
|
45
|
+
missing = ATTRIBUTES.first(7) - attributes.keys
|
|
46
|
+
raise ArgumentError, "prepared step contains unknown attributes: #{unknown.join(", ")}" if unknown.any?
|
|
47
|
+
raise ArgumentError, "prepared step is missing attributes: #{missing.join(", ")}" if missing.any?
|
|
48
|
+
end
|
|
49
|
+
private_class_method :validate_attributes!
|
|
50
|
+
|
|
51
|
+
def self.parse_attributes(value)
|
|
52
|
+
return validate_attribute_container!(value) if value.is_a?(Hash)
|
|
53
|
+
unless value.is_a?(String) && value.bytesize <= MAX_SERIALIZED_BYTES
|
|
54
|
+
raise ArgumentError, "prepared step must be a Hash or bounded JSON object"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
parsed = JSON.parse(value)
|
|
58
|
+
return validate_attribute_container!(parsed) if parsed.is_a?(Hash)
|
|
59
|
+
|
|
60
|
+
raise ArgumentError, "prepared step JSON must contain an object"
|
|
61
|
+
end
|
|
62
|
+
private_class_method :parse_attributes
|
|
63
|
+
|
|
64
|
+
def self.validate_attribute_container!(attributes)
|
|
65
|
+
unless attributes.size <= MAX_ATTRIBUTE_ENTRIES
|
|
66
|
+
raise ArgumentError, "prepared step contains too many attributes"
|
|
67
|
+
end
|
|
68
|
+
unless attributes.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
|
|
69
|
+
raise ArgumentError, "prepared step attribute names must be strings or symbols"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
attributes
|
|
73
|
+
end
|
|
74
|
+
private_class_method :validate_attribute_container!
|
|
75
|
+
|
|
76
|
+
def self.validate_bounded_values!(attributes)
|
|
77
|
+
bytes = attributes.sum { |key, value| bounded_attribute_bytes(key, value) }
|
|
78
|
+
return attributes if bytes <= MAX_SERIALIZED_BYTES
|
|
79
|
+
|
|
80
|
+
raise ArgumentError, "prepared step Hash exceeds maximum bytes"
|
|
81
|
+
end
|
|
82
|
+
private_class_method :validate_bounded_values!
|
|
83
|
+
|
|
84
|
+
def self.bounded_attribute_bytes(key, value)
|
|
85
|
+
validate_scalar_value!(value)
|
|
86
|
+
key.to_s.bytesize + (value.is_a?(String) ? value.bytesize : 8)
|
|
87
|
+
end
|
|
88
|
+
private_class_method :bounded_attribute_bytes
|
|
89
|
+
|
|
90
|
+
def self.validate_scalar_value!(value)
|
|
91
|
+
unless value.nil? || value.is_a?(String) || value.is_a?(Integer)
|
|
92
|
+
raise ArgumentError, "prepared step attribute values must be scalar"
|
|
93
|
+
end
|
|
94
|
+
return unless value.is_a?(Integer) && !value.between?(1, MAX_COUNTER_VALUE)
|
|
95
|
+
|
|
96
|
+
raise ArgumentError, "prepared step integer values must fit a positive signed 64-bit counter"
|
|
97
|
+
end
|
|
98
|
+
private_class_method :validate_scalar_value!
|
|
99
|
+
|
|
100
|
+
def self.normalize_attributes(attributes)
|
|
101
|
+
normalized = attributes.each_with_object({}) do |(key, nested), result|
|
|
102
|
+
normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
|
|
103
|
+
raise ArgumentError, "prepared step contains duplicate attributes" if result.key?(normalized_key)
|
|
104
|
+
|
|
105
|
+
result[normalized_key] = nested
|
|
106
|
+
end
|
|
107
|
+
normalized[:definition_digest] = nil unless normalized.key?(:definition_digest)
|
|
108
|
+
normalized
|
|
109
|
+
end
|
|
110
|
+
private_class_method :normalize_attributes
|
|
111
|
+
|
|
112
|
+
def initialize(attributes)
|
|
113
|
+
owned = attributes.dup
|
|
114
|
+
digest = owned[:definition_digest] || owned["definition_digest"]
|
|
115
|
+
owned[:definition_digest] = digest.dup.freeze if digest.is_a?(String)
|
|
116
|
+
super(owned)
|
|
117
|
+
self.attributes.freeze
|
|
118
|
+
freeze
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "../types"
|
|
7
|
+
require_relative "prepared_step"
|
|
8
|
+
|
|
9
|
+
module Smith
|
|
10
|
+
class Workflow
|
|
11
|
+
class PreparedStepDispatch < Dry::Struct
|
|
12
|
+
ATTRIBUTES = %i[prepared_step token dispatch_digest].freeze
|
|
13
|
+
MAX_ATTRIBUTE_ENTRIES = 8
|
|
14
|
+
MAX_SERIALIZED_BYTES = 8 * 1024
|
|
15
|
+
OwnedString = Types::String.constructor do |value|
|
|
16
|
+
value.is_a?(String) ? value.dup.freeze : value
|
|
17
|
+
end
|
|
18
|
+
private_constant :OwnedString
|
|
19
|
+
|
|
20
|
+
attribute :prepared_step, Types.Instance(PreparedStep)
|
|
21
|
+
attribute :token, OwnedString.constrained(format: PreparedStep::UUID_PATTERN)
|
|
22
|
+
attribute :dispatch_digest, OwnedString.constrained(format: PreparedStep::DIGEST_PATTERN)
|
|
23
|
+
|
|
24
|
+
def self.deserialize(value)
|
|
25
|
+
attributes = parse_attributes(value)
|
|
26
|
+
normalized = normalize_attributes(attributes)
|
|
27
|
+
validate_attributes!(normalized)
|
|
28
|
+
normalized[:prepared_step] = PreparedStep.deserialize(normalized[:prepared_step])
|
|
29
|
+
new(normalized)
|
|
30
|
+
rescue JSON::ParserError, TypeError => e
|
|
31
|
+
raise ArgumentError, "prepared-step dispatch is invalid: #{e.message}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.parse_attributes(value)
|
|
35
|
+
return value if value.is_a?(Hash) && bounded_hash?(value)
|
|
36
|
+
unless value.is_a?(String) && value.bytesize <= MAX_SERIALIZED_BYTES
|
|
37
|
+
raise ArgumentError, "prepared-step dispatch must be a bounded Hash or JSON object"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
parsed = JSON.parse(value)
|
|
41
|
+
return parsed if parsed.is_a?(Hash) && bounded_hash?(parsed)
|
|
42
|
+
|
|
43
|
+
raise ArgumentError, "prepared-step dispatch JSON must contain a bounded object"
|
|
44
|
+
end
|
|
45
|
+
private_class_method :parse_attributes
|
|
46
|
+
|
|
47
|
+
def self.bounded_hash?(attributes)
|
|
48
|
+
return false unless attributes.size <= MAX_ATTRIBUTE_ENTRIES
|
|
49
|
+
return false unless attributes.keys.all? { |key| key.is_a?(String) || key.is_a?(Symbol) }
|
|
50
|
+
|
|
51
|
+
attributes.sum { |key, value| key.to_s.bytesize + bounded_value_bytes(value) } <= MAX_SERIALIZED_BYTES
|
|
52
|
+
end
|
|
53
|
+
private_class_method :bounded_hash?
|
|
54
|
+
|
|
55
|
+
def self.bounded_value_bytes(value)
|
|
56
|
+
return value.bytesize if value.is_a?(String)
|
|
57
|
+
return PreparedStep::MAX_SERIALIZED_BYTES if value.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
MAX_SERIALIZED_BYTES + 1
|
|
60
|
+
end
|
|
61
|
+
private_class_method :bounded_value_bytes
|
|
62
|
+
|
|
63
|
+
def self.normalize_attributes(attributes)
|
|
64
|
+
attributes.each_with_object({}) do |(key, value), result|
|
|
65
|
+
normalized_key = key.respond_to?(:to_sym) ? key.to_sym : key
|
|
66
|
+
raise ArgumentError, "prepared-step dispatch contains duplicate attributes" if result.key?(normalized_key)
|
|
67
|
+
|
|
68
|
+
result[normalized_key] = value
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
private_class_method :normalize_attributes
|
|
72
|
+
|
|
73
|
+
def self.validate_attributes!(attributes)
|
|
74
|
+
unknown = attributes.keys - ATTRIBUTES
|
|
75
|
+
missing = ATTRIBUTES - attributes.keys
|
|
76
|
+
raise ArgumentError, "prepared-step dispatch contains unknown attributes: #{unknown.join(", ")}" if unknown.any?
|
|
77
|
+
raise ArgumentError, "prepared-step dispatch is missing attributes: #{missing.join(", ")}" if missing.any?
|
|
78
|
+
end
|
|
79
|
+
private_class_method :validate_attributes!
|
|
80
|
+
|
|
81
|
+
def initialize(attributes)
|
|
82
|
+
super
|
|
83
|
+
self.attributes.freeze
|
|
84
|
+
freeze
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "prepared_step"
|
|
4
|
+
require_relative "prepared_step_dispatch"
|
|
5
|
+
require_relative "prepared_step_execution_scope"
|
|
6
|
+
require_relative "split_step_persistence/execution_binding_snapshot"
|
|
7
|
+
require_relative "../errors"
|
|
8
|
+
|
|
9
|
+
module Smith
|
|
10
|
+
class Workflow
|
|
11
|
+
class PreparedStepExecutionAuthorization
|
|
12
|
+
attr_reader :prepared_step, :dispatch_claim
|
|
13
|
+
|
|
14
|
+
def initialize(prepared_step:, dispatch_claim:, execution_bindings:)
|
|
15
|
+
unless prepared_step.is_a?(PreparedStep)
|
|
16
|
+
raise ArgumentError, "prepared_step must be a Smith::Workflow::PreparedStep"
|
|
17
|
+
end
|
|
18
|
+
unless dispatch_claim.nil? || dispatch_claim.is_a?(PreparedStepDispatch)
|
|
19
|
+
raise ArgumentError, "dispatch_claim must be a Smith::Workflow::PreparedStepDispatch or nil"
|
|
20
|
+
end
|
|
21
|
+
if dispatch_claim && dispatch_claim.prepared_step.to_h != prepared_step.to_h
|
|
22
|
+
raise ArgumentError, "dispatch claim must belong to the prepared step"
|
|
23
|
+
end
|
|
24
|
+
unless execution_bindings.is_a?(SplitStepPersistence::ExecutionBindingSnapshot)
|
|
25
|
+
raise ArgumentError, "execution_bindings must be a Smith execution binding snapshot"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@prepared_step = prepared_step
|
|
29
|
+
@dispatch_claim = dispatch_claim
|
|
30
|
+
@execution_bindings = execution_bindings
|
|
31
|
+
@execution_scope = PreparedStepExecutionScope.new
|
|
32
|
+
@process_id = Process.pid
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def issued_in_current_process? = @process_id == Process.pid
|
|
37
|
+
|
|
38
|
+
def fetch_agent!(...)
|
|
39
|
+
ensure_binding_access!
|
|
40
|
+
|
|
41
|
+
@execution_bindings.fetch!(...)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def each_agent_binding(&block)
|
|
45
|
+
ensure_binding_access!
|
|
46
|
+
raise ArgumentError, "a block is required to inspect captured agent bindings" unless block
|
|
47
|
+
|
|
48
|
+
@execution_bindings.each_agent_binding(&block)
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def verify_workflow!(workflow_class)
|
|
53
|
+
ensure_active_execution!
|
|
54
|
+
@execution_bindings.verify_workflow!(workflow_class)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def active_in_current_execution?
|
|
58
|
+
issued_in_current_process? && @execution_scope.active_for?(Thread.current)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize_copy(_source)
|
|
62
|
+
raise TypeError, "prepared-step execution authorizations cannot be copied"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def _dump(_depth)
|
|
66
|
+
raise TypeError, "prepared-step execution authorizations cannot be serialized"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def encode_with(_coder)
|
|
70
|
+
raise TypeError, "prepared-step execution authorizations cannot be serialized"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def init_with(_coder)
|
|
74
|
+
raise TypeError, "prepared-step execution authorizations cannot be deserialized"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def as_json(*)
|
|
78
|
+
raise TypeError, "prepared-step execution authorizations cannot be serialized"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def to_json(*)
|
|
82
|
+
raise TypeError, "prepared-step execution authorizations cannot be serialized"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def activate_execution!(thread)
|
|
88
|
+
ensure_current_process!
|
|
89
|
+
@execution_scope.activate!(thread)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def close_execution!(thread = nil)
|
|
93
|
+
@execution_scope.close!(thread)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def ensure_active_execution!
|
|
97
|
+
return if active_in_current_execution?
|
|
98
|
+
|
|
99
|
+
raise WorkflowError, "prepared-step execution authorization is outside its active execution"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def ensure_binding_access!
|
|
103
|
+
accessible = issued_in_current_process? && @execution_scope.binding_accessible_for?(Thread.current)
|
|
104
|
+
return if accessible
|
|
105
|
+
|
|
106
|
+
raise WorkflowError, "prepared-step execution authorization is outside its binding access scope"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def ensure_current_process!
|
|
110
|
+
return if issued_in_current_process?
|
|
111
|
+
|
|
112
|
+
raise WorkflowError, "prepared-step execution authorization belongs to another process"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
5
|
+
require_relative "../types"
|
|
6
|
+
require_relative "execution_result_snapshot"
|
|
7
|
+
|
|
8
|
+
module Smith
|
|
9
|
+
class Workflow
|
|
10
|
+
class PreparedStepExecutionResult < Dry::Struct
|
|
11
|
+
attribute :status, Types::Symbol.enum(:succeeded, :failed)
|
|
12
|
+
attribute :step, Types::Hash
|
|
13
|
+
attribute? :error, Types.Instance(StandardError).optional
|
|
14
|
+
|
|
15
|
+
def self.from_step(step)
|
|
16
|
+
error = step[:error]
|
|
17
|
+
new(status: error ? :failed : :succeeded, step:, error:)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(attributes)
|
|
21
|
+
owned = attributes.dup
|
|
22
|
+
owned[:step] = snapshot(owned.fetch(:step), freeze_copy: true, preserved_error: owned[:error])
|
|
23
|
+
super(owned)
|
|
24
|
+
validate_shape!
|
|
25
|
+
self.attributes.freeze
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def succeeded? = status == :succeeded
|
|
30
|
+
def failed? = status == :failed
|
|
31
|
+
def step_snapshot = snapshot(step, freeze_copy: false, preserved_error: error)
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def snapshot(value, freeze_copy:, preserved_error:)
|
|
36
|
+
ExecutionResultSnapshot.new(value, freeze_copy:, preserved_error:).call
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def validate_shape!
|
|
40
|
+
valid = if failed?
|
|
41
|
+
error && step[:error].equal?(error)
|
|
42
|
+
else
|
|
43
|
+
error.nil? && !step.key?(:error)
|
|
44
|
+
end
|
|
45
|
+
raise ArgumentError, "prepared-step execution result fields do not match status" unless valid
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../errors"
|
|
4
|
+
|
|
5
|
+
module Smith
|
|
6
|
+
class Workflow
|
|
7
|
+
class PreparedStepExecutionScope
|
|
8
|
+
def initialize
|
|
9
|
+
@mutex = Mutex.new
|
|
10
|
+
@phase = :issued
|
|
11
|
+
@thread = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def activate!(thread)
|
|
15
|
+
@mutex.synchronize do
|
|
16
|
+
raise WorkflowError, "prepared-step execution scope is no longer available" unless @phase == :issued
|
|
17
|
+
|
|
18
|
+
@phase = :active
|
|
19
|
+
@thread = thread
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def close!(thread = nil)
|
|
24
|
+
@mutex.synchronize do
|
|
25
|
+
if @phase == :active && thread && !@thread.equal?(thread)
|
|
26
|
+
raise WorkflowError, "prepared-step execution scope belongs to another thread"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
@phase = :closed
|
|
30
|
+
@thread = nil
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def active_for?(thread)
|
|
35
|
+
@mutex.synchronize { @phase == :active && @thread.equal?(thread) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def binding_accessible_for?(thread)
|
|
39
|
+
@mutex.synchronize do
|
|
40
|
+
@phase == :issued || (@phase == :active && @thread.equal?(thread))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
5
|
+
require_relative "../types"
|
|
6
|
+
require_relative "prepared_step"
|
|
7
|
+
require_relative "prepared_step_dispatch"
|
|
8
|
+
|
|
9
|
+
module Smith
|
|
10
|
+
class Workflow
|
|
11
|
+
class PreparedStepRecovery < Dry::Struct
|
|
12
|
+
attribute :prepared_step, Types.Instance(PreparedStep)
|
|
13
|
+
attribute? :dispatch_claim, Types.Instance(PreparedStepDispatch).optional
|
|
14
|
+
attribute :execution_status, Types::Symbol.enum(:not_started)
|
|
15
|
+
|
|
16
|
+
def self.not_started(witness)
|
|
17
|
+
case witness
|
|
18
|
+
when PreparedStep
|
|
19
|
+
new(prepared_step: witness, dispatch_claim: nil, execution_status: :not_started)
|
|
20
|
+
when PreparedStepDispatch
|
|
21
|
+
new(prepared_step: witness.prepared_step, dispatch_claim: witness, execution_status: :not_started)
|
|
22
|
+
else
|
|
23
|
+
raise ArgumentError, "recovery witness must be a PreparedStep or PreparedStepDispatch"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(attributes)
|
|
28
|
+
super
|
|
29
|
+
if dispatch_claim && dispatch_claim.prepared_step.to_h != prepared_step.to_h
|
|
30
|
+
raise ArgumentError, "dispatch claim must belong to the prepared step"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
self.attributes.freeze
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -9,6 +9,7 @@ module Smith
|
|
|
9
9
|
config = transition.retry_config
|
|
10
10
|
return run_guarded_step(transition) unless config
|
|
11
11
|
|
|
12
|
+
schedule = retry_schedule(config)
|
|
12
13
|
attempt = 0
|
|
13
14
|
begin
|
|
14
15
|
attempt += 1
|
|
@@ -16,7 +17,7 @@ module Smith
|
|
|
16
17
|
rescue StandardError => e
|
|
17
18
|
raise unless retry_transition_error?(config, e, attempt)
|
|
18
19
|
|
|
19
|
-
sleep_for_retry(
|
|
20
|
+
sleep_for_retry(schedule, attempt)
|
|
20
21
|
retry
|
|
21
22
|
end
|
|
22
23
|
end
|
|
@@ -32,20 +33,28 @@ module Smith
|
|
|
32
33
|
end
|
|
33
34
|
end
|
|
34
35
|
|
|
35
|
-
def sleep_for_retry(
|
|
36
|
-
delay = retry_delay(
|
|
36
|
+
def sleep_for_retry(schedule, failed_attempt)
|
|
37
|
+
delay = retry_delay(schedule, failed_attempt)
|
|
37
38
|
sleep(delay) if delay.positive?
|
|
38
39
|
end
|
|
39
40
|
|
|
40
|
-
def retry_delay(
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
def retry_delay(config_or_schedule, failed_attempt)
|
|
42
|
+
schedule = if config_or_schedule.is_a?(ExponentialBackoff)
|
|
43
|
+
config_or_schedule
|
|
44
|
+
else
|
|
45
|
+
retry_schedule(config_or_schedule)
|
|
46
|
+
end
|
|
47
|
+
schedule.delay(failed_attempt, random: method(:rand))
|
|
48
|
+
end
|
|
44
49
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
def retry_schedule(config)
|
|
51
|
+
ExponentialBackoff.new(
|
|
52
|
+
attempts: config.fetch(:attempts),
|
|
53
|
+
base_delay: config.fetch(:backoff),
|
|
54
|
+
max_delay: config[:max_delay],
|
|
55
|
+
jitter: config.fetch(:jitter),
|
|
56
|
+
delay_label: "backoff"
|
|
57
|
+
)
|
|
49
58
|
end
|
|
50
59
|
end
|
|
51
60
|
end
|