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.
Files changed (152) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +262 -0
  3. data/README.md +116 -6
  4. data/docs/CONFIGURATION.md +4 -1
  5. data/docs/PATTERNS.md +7 -0
  6. data/docs/PERSISTENCE.md +502 -1
  7. data/docs/workflow_claim.md +4 -1
  8. data/lib/smith/agent/lifecycle.rb +19 -10
  9. data/lib/smith/agent/registry/execution_binding_capture.rb +26 -0
  10. data/lib/smith/agent/registry/mutation_boundary.rb +29 -0
  11. data/lib/smith/agent/registry.rb +8 -0
  12. data/lib/smith/agent.rb +24 -2
  13. data/lib/smith/budget/amount_contract.rb +67 -0
  14. data/lib/smith/budget/amount_representation.rb +43 -0
  15. data/lib/smith/budget/decimal_context.rb +18 -0
  16. data/lib/smith/budget/ledger.rb +110 -18
  17. data/lib/smith/budget/ledger_publication.rb +60 -0
  18. data/lib/smith/budget/ledger_state.rb +21 -0
  19. data/lib/smith/budget/ledger_state_transition.rb +72 -0
  20. data/lib/smith/budget/public_state_validator.rb +25 -0
  21. data/lib/smith/budget/reservation.rb +20 -0
  22. data/lib/smith/budget/reservation_contract.rb +34 -0
  23. data/lib/smith/context/observation_masking.rb +3 -1
  24. data/lib/smith/context/state_injection.rb +1 -1
  25. data/lib/smith/doctor/checks/persistence_capabilities.rb +18 -3
  26. data/lib/smith/error.rb +5 -0
  27. data/lib/smith/errors.rb +6 -0
  28. data/lib/smith/exponential_backoff.rb +128 -0
  29. data/lib/smith/persistence_adapters/active_record_connection_errors.rb +23 -0
  30. data/lib/smith/persistence_adapters/active_record_exact_predicate.rb +43 -0
  31. data/lib/smith/persistence_adapters/active_record_exact_store.rb +62 -0
  32. data/lib/smith/persistence_adapters/active_record_exact_write.rb +40 -0
  33. data/lib/smith/persistence_adapters/active_record_initial_write.rb +42 -0
  34. data/lib/smith/persistence_adapters/active_record_store.rb +95 -52
  35. data/lib/smith/persistence_adapters/cache_store.rb +4 -1
  36. data/lib/smith/persistence_adapters/memory.rb +45 -14
  37. data/lib/smith/persistence_adapters/payload_version.rb +23 -0
  38. data/lib/smith/persistence_adapters/redis_client_access.rb +38 -0
  39. data/lib/smith/persistence_adapters/redis_exact_write.rb +44 -0
  40. data/lib/smith/persistence_adapters/redis_store.rb +38 -43
  41. data/lib/smith/persistence_adapters/redis_versioned_write.rb +56 -0
  42. data/lib/smith/persistence_adapters/retry.rb +22 -10
  43. data/lib/smith/persistence_adapters/version_expectation.rb +19 -0
  44. data/lib/smith/persistence_adapters.rb +26 -18
  45. data/lib/smith/persistence_payload_conflict.rb +12 -0
  46. data/lib/smith/tool/budget_enforcement.rb +2 -2
  47. data/lib/smith/types.rb +1 -0
  48. data/lib/smith/version.rb +2 -1
  49. data/lib/smith/workflow/branch_env.rb +1 -1
  50. data/lib/smith/workflow/budget_integration.rb +21 -11
  51. data/lib/smith/workflow/definition_identity/class_methods.rb +48 -0
  52. data/lib/smith/workflow/definition_identity.rb +25 -0
  53. data/lib/smith/workflow/deterministic_step.rb +2 -2
  54. data/lib/smith/workflow/dsl.rb +73 -5
  55. data/lib/smith/workflow/durability.rb +2 -1
  56. data/lib/smith/workflow/evaluator_optimizer.rb +7 -11
  57. data/lib/smith/workflow/execution.rb +16 -18
  58. data/lib/smith/workflow/execution_binding_resolution.rb +16 -0
  59. data/lib/smith/workflow/execution_result_snapshot.rb +118 -0
  60. data/lib/smith/workflow/fanout_execution.rb +1 -1
  61. data/lib/smith/workflow/graph/diagnostic.rb +27 -1
  62. data/lib/smith/workflow/graph/diagnostic_path.rb +24 -0
  63. data/lib/smith/workflow/graph/execution_successors.rb +65 -0
  64. data/lib/smith/workflow/graph/identifier_projection.rb +13 -0
  65. data/lib/smith/workflow/graph/metrics.rb +1 -3
  66. data/lib/smith/workflow/graph/nested_readiness_diagnostics.rb +15 -16
  67. data/lib/smith/workflow/graph/optimization_contract.rb +2 -2
  68. data/lib/smith/workflow/graph/orchestration_contract.rb +3 -3
  69. data/lib/smith/workflow/graph/reachability.rb +19 -31
  70. data/lib/smith/workflow/graph/reachability_diagnostics.rb +17 -1
  71. data/lib/smith/workflow/graph/retry_policy_diagnostic.rb +36 -0
  72. data/lib/smith/workflow/graph/runtime_binding_diagnostics.rb +2 -2
  73. data/lib/smith/workflow/graph/runtime_readiness.rb +3 -44
  74. data/lib/smith/workflow/graph/runtime_readiness_metrics.rb +2 -2
  75. data/lib/smith/workflow/graph/runtime_readiness_report_builder.rb +47 -0
  76. data/lib/smith/workflow/graph/runtime_readiness_traversal.rb +108 -0
  77. data/lib/smith/workflow/graph/state_diagnostics.rb +2 -2
  78. data/lib/smith/workflow/graph/transition_contract.rb +47 -0
  79. data/lib/smith/workflow/graph/transition_contract_attributes.rb +73 -0
  80. data/lib/smith/workflow/graph/transition_contract_configurations.rb +100 -0
  81. data/lib/smith/workflow/graph/transition_diagnostics.rb +54 -12
  82. data/lib/smith/workflow/graph/transition_optimization_configuration.rb +62 -0
  83. data/lib/smith/workflow/graph/transition_snapshot.rb +4 -1
  84. data/lib/smith/workflow/graph/validator.rb +1 -1
  85. data/lib/smith/workflow/graph.rb +62 -4
  86. data/lib/smith/workflow/guardrail_integration.rb +23 -9
  87. data/lib/smith/workflow/identifier.rb +24 -0
  88. data/lib/smith/workflow/message_admission.rb +40 -0
  89. data/lib/smith/workflow/message_admission_boundary.rb +22 -0
  90. data/lib/smith/workflow/message_batch.rb +60 -0
  91. data/lib/smith/workflow/message_value_normalizer.rb +133 -0
  92. data/lib/smith/workflow/nested_execution.rb +10 -0
  93. data/lib/smith/workflow/orchestrator_worker.rb +3 -7
  94. data/lib/smith/workflow/parallel/cancellation_signal.rb +14 -5
  95. data/lib/smith/workflow/parallel/execution_context.rb +113 -0
  96. data/lib/smith/workflow/parallel/nested_execution.rb +124 -0
  97. data/lib/smith/workflow/parallel/root_execution.rb +91 -0
  98. data/lib/smith/workflow/parallel.rb +19 -22
  99. data/lib/smith/workflow/parallel_agent_binding.rb +24 -0
  100. data/lib/smith/workflow/parallel_execution.rb +7 -3
  101. data/lib/smith/workflow/persistence.rb +49 -10
  102. data/lib/smith/workflow/prepared_step.rb +122 -0
  103. data/lib/smith/workflow/prepared_step_dispatch.rb +88 -0
  104. data/lib/smith/workflow/prepared_step_execution_authorization.rb +116 -0
  105. data/lib/smith/workflow/prepared_step_execution_result.rb +49 -0
  106. data/lib/smith/workflow/prepared_step_execution_scope.rb +45 -0
  107. data/lib/smith/workflow/prepared_step_recovery.rb +38 -0
  108. data/lib/smith/workflow/retry_execution.rb +20 -11
  109. data/lib/smith/workflow/split_step_persistence/boundary.rb +128 -0
  110. data/lib/smith/workflow/split_step_persistence/boundary_reset.rb +50 -0
  111. data/lib/smith/workflow/split_step_persistence/canonical_payload_digest.rb +55 -0
  112. data/lib/smith/workflow/split_step_persistence/checkpoint.rb +119 -0
  113. data/lib/smith/workflow/split_step_persistence/checkpoint_state.rb +51 -0
  114. data/lib/smith/workflow/split_step_persistence/definition_boundary.rb +19 -0
  115. data/lib/smith/workflow/split_step_persistence/dispatch_boundary.rb +16 -0
  116. data/lib/smith/workflow/split_step_persistence/dispatch_claim.rb +118 -0
  117. data/lib/smith/workflow/split_step_persistence/dispatch_confirmation.rb +70 -0
  118. data/lib/smith/workflow/split_step_persistence/dispatch_verification.rb +35 -0
  119. data/lib/smith/workflow/split_step_persistence/execution.rb +128 -0
  120. data/lib/smith/workflow/split_step_persistence/execution_authorization.rb +89 -0
  121. data/lib/smith/workflow/split_step_persistence/execution_binding_collector.rb +90 -0
  122. data/lib/smith/workflow/split_step_persistence/execution_binding_snapshot.rb +95 -0
  123. data/lib/smith/workflow/split_step_persistence/execution_result_capture.rb +48 -0
  124. data/lib/smith/workflow/split_step_persistence/execution_verification.rb +65 -0
  125. data/lib/smith/workflow/split_step_persistence/execution_workflow_snapshot.rb +77 -0
  126. data/lib/smith/workflow/split_step_persistence/inheritance.rb +22 -0
  127. data/lib/smith/workflow/split_step_persistence/payloads.rb +54 -0
  128. data/lib/smith/workflow/split_step_persistence/preparation.rb +125 -0
  129. data/lib/smith/workflow/split_step_persistence/preparation_claim.rb +117 -0
  130. data/lib/smith/workflow/split_step_persistence/preparation_payload.rb +25 -0
  131. data/lib/smith/workflow/split_step_persistence/preparation_recovery.rb +54 -0
  132. data/lib/smith/workflow/split_step_persistence/recovery.rb +125 -0
  133. data/lib/smith/workflow/split_step_persistence/recovery_boundary.rb +56 -0
  134. data/lib/smith/workflow/split_step_persistence/recovery_class_methods.rb +14 -0
  135. data/lib/smith/workflow/split_step_persistence/replace_exact_signature.rb +46 -0
  136. data/lib/smith/workflow/split_step_persistence/restart_safe_adapter.rb +42 -0
  137. data/lib/smith/workflow/split_step_persistence/state_snapshot.rb +84 -0
  138. data/lib/smith/workflow/split_step_persistence/subclass_boundary.rb +102 -0
  139. data/lib/smith/workflow/split_step_persistence/transaction_identity.rb +45 -0
  140. data/lib/smith/workflow/split_step_persistence/transition_contract.rb +48 -0
  141. data/lib/smith/workflow/split_step_persistence/transition_contract_freezer.rb +83 -0
  142. data/lib/smith/workflow/split_step_persistence/transition_contract_signature.rb +107 -0
  143. data/lib/smith/workflow/split_step_persistence/transition_contract_structured_values.rb +51 -0
  144. data/lib/smith/workflow/split_step_persistence.rb +79 -0
  145. data/lib/smith/workflow/step_completion.rb +31 -0
  146. data/lib/smith/workflow/string_snapshot.rb +25 -0
  147. data/lib/smith/workflow/transition.rb +122 -133
  148. data/lib/smith/workflow/transition_actionability.rb +22 -0
  149. data/lib/smith/workflow.rb +57 -19
  150. data/lib/smith.rb +42 -2
  151. data/sig/smith.rbs +1 -0
  152. metadata +131 -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
@@ -48,7 +53,7 @@ module Smith
48
53
  # workflow class opts into idempotency_mode :strict. Restore
49
54
  # raises Smith::StepInProgressOnRestore if true under strict
50
55
  # mode. Lax mode leaves this false and never raises.
51
- step_in_progress: @step_in_progress || false,
56
+ step_in_progress: @step_in_progress || !@split_step_phase.nil?,
52
57
  # Keys recorded via DeterministicStep#write_context. Used by
53
58
  # persist :auto Context mode to scope the persisted context
54
59
  # slice. Always emitted (sorted for stable diffing) so
@@ -59,9 +64,12 @@ 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)
72
+ persistence_version = validated_persistence_version(normalized)
65
73
  restore_persisted_keys(normalized)
66
74
  restore_core_fields(normalized)
67
75
  @persistence_key = normalized[:persistence_key]
@@ -87,7 +95,8 @@ module Smith
87
95
  # Backward-compat: pre-versioning payloads have no key, restore to 0
88
96
  # so the first persist! after restore expects version 0 (matches
89
97
  # the original store from the legacy adapter contract).
90
- @persistence_version = normalized[:persistence_version] || 0
98
+ @persistence_version = persistence_version
99
+ validate_definition_digest!(normalized)
91
100
  # Preserve the seed digest from the persisted payload so it
92
101
  # round-trips on subsequent persists. validate_seed_digest!
93
102
  # compares this against a fresh evaluation of the seed builder
@@ -98,7 +107,29 @@ module Smith
98
107
  # round-trips it. validate_step_in_progress! enforces strict
99
108
  # mode by raising if the marker is set on restore.
100
109
  @step_in_progress = normalized[:step_in_progress] || false
101
- validate_step_in_progress!(normalized) if self.class.idempotency_mode == :strict
110
+ @split_step_mutex = Mutex.new
111
+ validate_step_in_progress!(normalized) if
112
+ self.class.idempotency_mode == :strict && !allow_step_in_progress
113
+ end
114
+
115
+ def validated_persistence_version(normalized)
116
+ version = normalized.fetch(:persistence_version, 0)
117
+ return version if version.is_a?(Integer) && version >= 0
118
+
119
+ raise Smith::SerializationError,
120
+ "persisted workflow persistence_version must be a non-negative integer, got #{version.inspect}"
121
+ end
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"
102
133
  end
103
134
 
104
135
  def validate_step_in_progress!(normalized)
@@ -110,6 +141,14 @@ module Smith
110
141
  )
111
142
  end
112
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
+
113
152
  def validate_seed_digest!(normalized)
114
153
  stored_digest = normalized[:seed_digest]
115
154
  return if stored_digest.nil?
@@ -246,13 +285,13 @@ module Smith
246
285
  end
247
286
 
248
287
  def normalize_persisted_state(hash)
249
- 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)
250
290
  normalize_symbol_fields!(normalized)
251
291
  normalize_nested_hashes!(normalized)
252
292
  normalize_session_messages!(normalized)
253
293
  normalize_tool_results!(normalized)
254
294
  normalize_usage_entries!(normalized)
255
- normalized[:outcome] = symbolize_value(normalized[:outcome]) if normalized[:outcome].is_a?(Hash)
256
295
  normalized
257
296
  end
258
297
 
@@ -308,7 +347,7 @@ module Smith
308
347
  return unless normalized[:session_messages].is_a?(Array)
309
348
 
310
349
  normalized[:session_messages] = normalized[:session_messages].map do |msg|
311
- msg.is_a?(Hash) ? symbolize_keys(msg) : msg
350
+ msg.is_a?(Hash) ? symbolize_keys(snapshot_value(msg)) : snapshot_value(msg)
312
351
  end
313
352
  end
314
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