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.
Files changed (140) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +201 -0
  3. data/README.md +108 -4
  4. data/docs/CONFIGURATION.md +4 -1
  5. data/docs/PATTERNS.md +7 -0
  6. data/docs/PERSISTENCE.md +347 -25
  7. data/lib/smith/agent/lifecycle.rb +19 -10
  8. data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
  9. data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
  10. data/lib/smith/agent/registry.rb +8 -0
  11. data/lib/smith/agent.rb +24 -2
  12. data/lib/smith/budget/amount_contract.rb +67 -0
  13. data/lib/smith/budget/amount_representation.rb +43 -0
  14. data/lib/smith/budget/decimal_context.rb +18 -0
  15. data/lib/smith/budget/ledger.rb +110 -18
  16. data/lib/smith/budget/ledger_publication.rb +60 -0
  17. data/lib/smith/budget/ledger_state.rb +21 -0
  18. data/lib/smith/budget/ledger_state_transition.rb +72 -0
  19. data/lib/smith/budget/public_state_validator.rb +25 -0
  20. data/lib/smith/budget/reservation.rb +20 -0
  21. data/lib/smith/budget/reservation_contract.rb +34 -0
  22. data/lib/smith/context/observation_masking.rb +3 -1
  23. data/lib/smith/context/state_injection.rb +1 -1
  24. data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -4
  25. data/lib/smith/error.rb +5 -0
  26. data/lib/smith/errors.rb +6 -0
  27. data/lib/smith/exponential_backoff.rb +128 -0
  28. data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
  29. data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
  30. data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
  31. data/lib/smith/persistence_adapters/active_record_store.rb +7 -4
  32. data/lib/smith/persistence_adapters/cache_store.rb +1 -0
  33. data/lib/smith/persistence_adapters/memory.rb +16 -1
  34. data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
  35. data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
  36. data/lib/smith/persistence_adapters/redis_store.rb +28 -11
  37. data/lib/smith/persistence_adapters/retry.rb +22 -10
  38. data/lib/smith/persistence_adapters.rb +21 -18
  39. data/lib/smith/persistence_payload_conflict.rb +12 -0
  40. data/lib/smith/tool/budget_enforcement.rb +2 -2
  41. data/lib/smith/types.rb +1 -0
  42. data/lib/smith/version.rb +2 -1
  43. data/lib/smith/workflow/branch_env.rb +1 -1
  44. data/lib/smith/workflow/budget_integration.rb +21 -11
  45. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  46. data/lib/smith/workflow/definition_identity.rb +25 -0
  47. data/lib/smith/workflow/deterministic_step.rb +2 -2
  48. data/lib/smith/workflow/dsl.rb +73 -5
  49. data/lib/smith/workflow/durability.rb +1 -1
  50. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  51. data/lib/smith/workflow/execution.rb +16 -18
  52. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  53. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  54. data/lib/smith/workflow/fanout_execution.rb +1 -1
  55. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  56. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  57. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  58. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  59. data/lib/smith/workflow/graph/metrics.rb +1 -3
  60. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  61. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  62. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  63. data/lib/smith/workflow/graph/reachability.rb +19 -31
  64. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  65. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  66. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  67. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  68. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  69. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  70. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  71. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  72. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  73. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  74. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  75. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  76. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  77. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  78. data/lib/smith/workflow/graph/validator.rb +1 -1
  79. data/lib/smith/workflow/graph.rb +62 -4
  80. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  81. data/lib/smith/workflow/identifier.rb +24 -0
  82. data/lib/smith/workflow/message_admission.rb +40 -0
  83. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  84. data/lib/smith/workflow/message_batch.rb +60 -0
  85. data/lib/smith/workflow/message_value_normalizer.rb +133 -0
  86. data/lib/smith/workflow/nested_execution.rb +10 -0
  87. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  88. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  89. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  90. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  91. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  92. data/lib/smith/workflow/parallel.rb +19 -22
  93. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  94. data/lib/smith/workflow/parallel_execution.rb +7 -3
  95. data/lib/smith/workflow/persistence.rb +37 -8
  96. data/lib/smith/workflow/prepared_step.rb +122 -0
  97. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  98. data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
  99. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  100. data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
  101. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  102. data/lib/smith/workflow/retry_execution.rb +20 -11
  103. data/lib/smith/workflow/split_step_persistence/boundary.rb +35 -22
  104. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  105. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  106. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +3 -5
  107. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  108. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  109. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  110. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  111. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  112. data/lib/smith/workflow/split_step_persistence/execution.rb +49 -47
  113. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
  114. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
  115. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
  116. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  117. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
  118. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  119. data/lib/smith/workflow/split_step_persistence/inheritance.rb +3 -1
  120. data/lib/smith/workflow/split_step_persistence/payloads.rb +7 -3
  121. data/lib/smith/workflow/split_step_persistence/preparation.rb +30 -2
  122. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +38 -12
  123. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  124. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +10 -2
  125. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  126. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  127. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  128. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  129. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  130. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +50 -0
  131. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  132. data/lib/smith/workflow/split_step_persistence.rb +36 -0
  133. data/lib/smith/workflow/step_completion.rb +31 -0
  134. data/lib/smith/workflow/string_snapshot.rb +25 -0
  135. data/lib/smith/workflow/transition.rb +122 -133
  136. data/lib/smith/workflow/transition_actionability.rb +22 -0
  137. data/lib/smith/workflow.rb +44 -18
  138. data/lib/smith.rb +41 -2
  139. data/sig/smith.rbs +1 -0
  140. 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: @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 self.class.idempotency_mode == :strict
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(config, attempt)
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(config, failed_attempt)
36
- delay = retry_delay(config, failed_attempt)
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(config, failed_attempt)
41
- delay = config.fetch(:backoff) * (2**[failed_attempt - 1, 0].max)
42
- max_delay = config[:max_delay]
43
- delay = [delay, max_delay].min if max_delay
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
- jitter = config.fetch(:jitter)
46
- delay += rand * jitter if jitter.positive?
47
- delay = [delay, max_delay].min if max_delay
48
- delay
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